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

Draft
wants to merge 29 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
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
4 changes: 2 additions & 2 deletions cypress/component/UserProfile/user_profile.spec.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/* eslint-disable no-undef */

import {mount} from 'cypress/react';
import React from 'react';
import {Storage} from '../../../src/libs/storage';
import {AuthenticateNIH} from '../../../src/libs/ajax/AuthenticateNIH';
import {User} from '../../../src/libs/ajax/User';
import {Institution} from '../../../src/libs/ajax/Institution';
import UserProfile from '../../../src/pages/user_profile/UserProfile';
Expand Down Expand Up @@ -32,6 +31,7 @@ describe('User Profile', () => {
cy.stub(User, 'getMe').returns(duosUser);
cy.stub(User, 'getApprovedDatasets').returns([]);
cy.stub(User, 'getAcknowledgements').returns({});
cy.stub(AuthenticateNIH, 'getECMAccountStatus').returns(undefined);
cy.intercept(
{method: 'PUT', url: '**/user'},
{statusCode: 200, body: duosUser}
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
35 changes: 32 additions & 3 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
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';
Expand All @@ -12,12 +12,13 @@ import {SpinnerComponent as Spinner} from './components/SpinnerComponent';
import {StackdriverReporter} from './libs/stackdriverReporter';
import {Storage} from './libs/storage';
import Routes from './Routes';
import {AuthenticateNIH} from '../src/libs/ajax/AuthenticateNIH.js';

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 });
Expand Down Expand Up @@ -65,6 +66,34 @@ function App() {
setUserIsLogged();
});

// Check for NIH Authentication URL params that need to be parsed
useEffect(() => {
const checkNIHAuth = 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);
console.log(linkInfo);
if (linkInfo?.externalUserId) {
// TODO: Construct a {
// "nihUsername": "string",
// "datasetPermissions": [
// "string"
// ],
// "status": "string",
// "eraExpiration": "string"
// }
// and post to AuthenticateNIH.saveNihUsr(nihPayload);
}
if (linkInfo?.additionalState?.redirectTo) {
window.location.href = linkInfo.additionalState.redirectTo;
}
}
};
checkNIHAuth();
});

return (
<div className="body">
<div className="wrap">
Expand Down
21 changes: 18 additions & 3 deletions src/components/ERACommons.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ 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} from '../../src/utils/ERACommonsUtils';
import ReactTooltip from 'react-tooltip';

export default function ERACommons(props) {
Expand Down Expand Up @@ -62,6 +62,13 @@ export default function ERACommons(props) {
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(eraAuthState.eraCommonsId);
onNihStatusUpdate(eraAuthState.nihValid);
// TODO Testing code to replace old functionality with:
try {
const ecmResponse = AuthenticateNIH.getECMAccountStatus();
console.log('ecmResponse', ecmResponse);
} catch (err) {
console.log(err);
}
};
initResearcherProfile();
}, [researcherProfile, onNihStatusUpdate]);
Expand All @@ -71,14 +78,22 @@ export default function ERACommons(props) {
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 () => {
const deleteResponse = await AuthenticateNIH.deleteAccountLinkage();
if (deleteResponse) {
const response = await User.getMe();
const eraAuthState = extractEraAuthenticationState(response.properties);
setAuthorized(eraAuthState.isAuthorized);
setExpirationCount(eraAuthState.expirationCount);
setEraCommonsId(researcherProfile.eraCommonsId);
setEraCommonsId(undefined);
onNihStatusUpdate(eraAuthState.nihValid);
setSearch('');
} else {
Expand All @@ -101,7 +116,7 @@ export default function ERACommons(props) {
<a
data-cy='era-commons-authenticate-link'
className={validationErrorState ? 'era-button-state-error' : 'era-button-state'}
onClick={redirectToNihLogin}
onClick={rasEnabled() ? redirectToECMAuthUrl : redirectToNihLogin}
target='_blank'>
<div className={'era-logo-style'}/>
<span style={{verticalAlign: '50%'}}>Authenticate your account</span>
Expand Down
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
59 changes: 52 additions & 7 deletions src/libs/ajax/AuthenticateNIH.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,63 @@
import {mergeAll} from 'lodash/fp';
import { Config } from '../config';
import { getApiUrl, fetchOk } from '../ajax';
import axios from 'axios';
import {Config} from '../config';
import {getECMUrl, getApiUrl, reportError} from '../ajax';
import {get, isNil, merge} from 'lodash';

/**
* ECM has several different providers such as `era-commons`, `ras`, `github`, `fence`, and others.
* @type {string}
*/
const provider = 'ras';

axios.interceptors.response.use(function (response) {
return response;
}, function (error) {
// Default to a 502 when we can't get a real response object.
const status = get(error, 'response.status', 502);
const reportUrl = get(error, 'response.config.url', null);
if (!isNil(reportUrl) && status >= 500) {
reportError(reportUrl, status);
}
return Promise.reject(error);
});

export const AuthenticateNIH = {
saveNihUsr: async (decodedData) => {
const url = `${await getApiUrl()}/api/nih`;
const res = await fetchOk(url, mergeAll([Config.authOpts(), Config.jsonBody(decodedData), { method: 'POST' }]));
return await res.json();
const res = await axios.post(url, JSON.stringify(decodedData), merge(Config.authOpts(), {headers: {'Content-Type': 'application/json'}}));
return await res.data;
},

deleteAccountLinkage: async () => {
const url = `${await getApiUrl()}/api/nih`;
const res = await fetchOk(url, mergeAll([Config.authOpts(), { method: 'DELETE' }]));
return await res;
return await axios.delete(url, Config.authOpts());
},

getECMAccountStatus: async () => {
const url = `${await getECMUrl()}/api/oauth/v1/${provider}`;
const res = await axios.get(url, Config.authOpts());
if (res.status === 200) {
return res.data;
}
return undefined;
},

getECMProviderAuthUrl: async (redirectUri, redirectTo) => {
const url = `${await getECMUrl()}/api/oauth/v1/${provider}/authorization-url?redirectUri=${redirectUri}`;
const res = await axios.post(url, {redirectTo: redirectTo}, Config.authOpts());
if (res.status === 200) {
return res.data;
}
return undefined;
},

getECMProviderLinkInfo: async (code, state) => {
const url = `${await getECMUrl()}/api/oauth/v1/${provider}/oauthcode?state=${state}&oauthcode=${code}`;
const res = await axios.post(url, null, Config.authOpts());
if (res.status === 200) {
return res.data;
}
return undefined;
},

};
2 changes: 2 additions & 0 deletions src/libs/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const Config = {

getOntologyApiUrl: async () => (await getConfig()).ontologyApiUrl,

getECMUrl: async () => (await getConfig()).ecmApiUrl,

getTdrApiUrl: async () => (await getConfig()).tdrApiUrl,

getTerraUrl: async () => (await getConfig()).terraUrl,
Expand Down
21 changes: 12 additions & 9 deletions src/utils/ERACommonsUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import {Buffer} from 'buffer';
import {find, getOr, isNil} from 'lodash/fp';
import EnvironmentUtils, {envGroups} from '../utils/EnvironmentUtils.js';

export const rasEnabled = () => {
return EnvironmentUtils.checkEnv(envGroups.DEV);
}

/**
* This function is used to verify the raw NIH token and return the decoded data.
Expand Down Expand Up @@ -27,7 +31,7 @@ export const decodeNihToken = async (token) => {
return null;
}
return JSON.parse(parts[1]);
} catch (err) {
} catch (_error) {
return null;
}
};
Expand Down Expand Up @@ -56,7 +60,7 @@ export const expirationCountFromDate = (expDate) => {
* @returns {Date}
*/
export const treatAsUTC = (date) => {
let result = new Date(date);
const result = new Date(date);
result.setMinutes(result.getMinutes() - result.getTimezoneOffset());
return result;
};
Expand All @@ -67,12 +71,11 @@ export const treatAsUTC = (date) => {
* @param user The user to derive era authentication state from
*/
export const extractEraAuthenticationState = (user) => {
// The user object, confusingly, sometimes has a list of `properties` and sometimes has a list of `researcherProperties`.
const properties = user.properties || user.researcherProperties;
const authProp = find({'propertyKey':'eraAuthorized'})(properties);
const expProp = find({'propertyKey':'eraExpiration'})(properties);
const isAuthorized = isNil(authProp) ? false : getOr(false,'propertyValue')(authProp);
const expirationCount = isNil(expProp) ? 0 : expirationCountFromDate(getOr(0,'propertyValue')(expProp));
const properties = user.properties;
const authProp = properties?.find(p => p.propertyKey === 'eraAuthorized');
const expProp = properties?.find(p => p.propertyKey === 'eraExpiration');
const isAuthorized = authProp?.propertyValue ?? false;
const expirationCount = expProp?.propertyValue ? expirationCountFromDate(expProp.propertyValue) : 0;
const nihValid = isAuthorized && expirationCount > 0;
return {
isAuthorized,
Expand Down
Loading