Skip to content

Commit

Permalink
Implement OAuth authorization code grant flow for sign in.
Browse files Browse the repository at this point in the history
This replaces the password grant flow.
  • Loading branch information
ray-lee committed Sep 20, 2023
1 parent a15e92a commit ecef118
Show file tree
Hide file tree
Showing 78 changed files with 3,824 additions and 4,638 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Change Log

## v9.0.0

v9.0.0 adds support for CollectionSpace 8.0.

### Breaking Changes

- Sign in now uses the OAuth 2 authorization code grant, supported in CollectionSpace 8. If this version of cspace-ui is used with an older CollectionSpace server, users will not be able to sign in.

## v8.0.0

v8.0.0 adds support for CollectionSpace 7.2.
Expand Down
1 change: 1 addition & 0 deletions images/key.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions images/lockReset.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions images/logout.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions images/send.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3,428 changes: 1,900 additions & 1,528 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cspace-ui",
"version": "8.0.0",
"version": "9.0.0-dev",
"description": "CollectionSpace user interface for browsers",
"author": "Ray Lee <[email protected]>",
"license": "ECL-2.0",
Expand Down Expand Up @@ -43,7 +43,7 @@
},
"dependencies": {
"classnames": "^2.2.5",
"cspace-client": "^1.1.8",
"cspace-client": "^2.0.0-rc.1",
"cspace-input": "^2.0.4",
"cspace-layout": "^2.0.2",
"cspace-refname": "^1.0.4",
Expand Down
4 changes: 2 additions & 2 deletions src/actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ The actions dispatched in the cspace-ui application are divided into functionall
Some action creators are synchronous, resulting in immediate state updates when dispatched. A synchronous action creator is recognizable by its single [fat-arrow](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) signature, defining a function that returns an action object:

```
export const resetLogin = () => ({
type: RESET_LOGIN,
export const toggleRecordSidebar = () => ({
type: TOGGLE_RECORD_SIDEBAR,
});
```

Expand Down
24 changes: 9 additions & 15 deletions src/actions/cspace.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@ import { openModal } from './notification';

import {
CSPACE_CONFIGURED,
RESET_LOGIN,
SYSTEM_INFO_READ_FULFILLED,
SYSTEM_INFO_READ_REJECTED,
} from '../constants/actionCodes';

let client;

export const createSession = (username, password) => {
if (typeof username === 'undefined' && typeof password === 'undefined') {
export const createSession = (authCode, codeVerifier, redirectUri) => {
if (typeof authCode === 'undefined') {
return client.session();
}

return client.session({
username,
password,
authCode,
codeVerifier,
redirectUri,
});
};

Expand Down Expand Up @@ -59,18 +59,12 @@ export const configureCSpace = (config) => (dispatch) => {
const status = get(error, ['response', 'status']);

if (status === 401) {
const internalError = get(error, ['response', 'data', 'error']);
const headers = get(error, ['response', 'headers']);
const wwwAuthenticate = headers.get('Www-Authenticate');

if (internalError === 'invalid_token') {
if (wwwAuthenticate && wwwAuthenticate.includes('invalid_token')) {
// The stored token is no longer valid. Show the login modal.

dispatch({
type: RESET_LOGIN,
meta: {
username: getSession().config().username,
},
});

dispatch(openModal(MODAL_LOGIN));
}
}
Expand All @@ -83,7 +77,7 @@ export const configureCSpace = (config) => (dispatch) => {

dispatch(setSession(newSession));

const { username } = newSession.config();
const username = newSession.username();

if (!username) {
return Promise.resolve();
Expand Down
184 changes: 152 additions & 32 deletions src/actions/login.js
Original file line number Diff line number Diff line change
@@ -1,57 +1,59 @@
/* global window */

import get from 'lodash/get';
import qs from 'qs';
import { readAuthVocabs } from './authority';
import { createSession, setSession } from './cspace';
import { loadPrefs, savePrefs } from './prefs';
import { readAccountRoles } from './account';
import { getUserUsername } from '../reducers';

import {
ERR_INVALID_CREDENTIALS,
ERR_ACCOUNT_INACTIVE,
ERR_ACCOUNT_INVALID,
ERR_ACCOUNT_NOT_FOUND,
ERR_AUTH_CODE_REQUEST_NOT_FOUND,
ERR_NETWORK,
ERR_WRONG_TENANT,
} from '../constants/errorCodes';

import {
AUTH_CODE_URL_CREATED,
AUTH_RENEW_FULFILLED,
AUTH_RENEW_REJECTED,
RESET_LOGIN,
LOGIN_STARTED,
LOGIN_FULFILLED,
LOGIN_REJECTED,
} from '../constants/actionCodes';

export const resetLogin = (username) => ({
type: RESET_LOGIN,
meta: {
username,
},
});
const renewAuth = (config, authCode, authCodeRequestData = {}) => (dispatch) => {
const {
codeVerifier,
redirectUri,
} = authCodeRequestData;

const session = createSession(authCode, codeVerifier, redirectUri);
const loginPromise = authCode ? session.login() : Promise.resolve();

const renewAuth = (config, username, password) => (dispatch) => {
const session = createSession(username, password);
let username = null;

return session.login()
return loginPromise
.then(() => session.read('accounts/0/accountperms'))
.then((response) => {
if (get(response, ['data', 'ns2:account_permission', 'account', 'tenantId']) !== config.tenantId) {
// The logged in user doesn't belong to the tenant that this UI expects.

return session.logout()
// TODO: Use .finally when it's supported in all browsers.
.then(() => {
const error = new Error();
error.code = ERR_WRONG_TENANT;

return Promise.reject(error);
})
.catch(() => {
.finally(() => {
const error = new Error();
error.code = ERR_WRONG_TENANT;

return Promise.reject(error);
});
}

username = get(response, ['data', 'ns2:account_permission', 'account', 'userId']);

dispatch(setSession(session));

return dispatch({
Expand All @@ -64,15 +66,24 @@ const renewAuth = (config, username, password) => (dispatch) => {
});
})
.then(() => dispatch(readAccountRoles(config, username)))
.then(() => Promise.resolve(username))
.catch((error) => {
let { code } = error;

const desc = get(error, ['response', 'data', 'error_description']) || get(error, 'message');
const data = get(error, ['response', 'data']) || '';

if (/invalid state/.test(data)) {
code = ERR_ACCOUNT_INVALID;

Check warning on line 76 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L76

Added line #L76 was not covered by tests
} else if (/inactive/.test(data)) {
code = ERR_ACCOUNT_INACTIVE;

Check warning on line 78 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L78

Added line #L78 was not covered by tests
} else if (/account not found/.test(data)) {
code = ERR_ACCOUNT_NOT_FOUND;

Check warning on line 80 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L80

Added line #L80 was not covered by tests
} else {
const desc = get(error, ['response', 'data', 'error_description']) || get(error, 'message');

if (desc === 'Bad credentials') {
code = ERR_INVALID_CREDENTIALS;
} else if (desc === 'Network Error') {
code = ERR_NETWORK;
if (desc === 'Network Error') {
code = ERR_NETWORK;
}
}

dispatch({
Expand All @@ -94,33 +105,142 @@ const renewAuth = (config, username, password) => (dispatch) => {
});
};

export const login = (config, username, password) => (dispatch, getState) => {
const generateS256Hash = async (input) => {
const inputBytes = new TextEncoder().encode(input);
const sha256Bytes = await window.crypto.subtle.digest('SHA-256', inputBytes);
const base64 = window.btoa(String.fromCharCode(...new Uint8Array(sha256Bytes)));

Check warning on line 111 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L109-L111

Added lines #L109 - L111 were not covered by tests

const urlSafeBase64 = base64

Check warning on line 113 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L113

Added line #L113 was not covered by tests
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');

return urlSafeBase64;

Check warning on line 118 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L118

Added line #L118 was not covered by tests
};

const authCodeRequestRedirectUrl = (serverUrl) => {
const currentUrl = window.location.href;
const authorizedUrl = new URL('authorized', currentUrl);

Check warning on line 123 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L122-L123

Added lines #L122 - L123 were not covered by tests

if (!serverUrl) {
return `/..${authorizedUrl.pathname}`;

Check warning on line 126 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L125-L126

Added lines #L125 - L126 were not covered by tests
}

return authorizedUrl.toString();

Check warning on line 129 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L129

Added line #L129 was not covered by tests
};

const authCodeRequestStorageKey = (requestId) => `authCodeRequest:${requestId}`;

export const createAuthCodeUrl = (config, landingPath) => async (dispatch) => {
const {
serverUrl,
} = config;

Check warning on line 137 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L137

Added line #L137 was not covered by tests

const requestId = window.crypto.randomUUID();
const codeVerifier = window.crypto.randomUUID();
const codeChallenge = await generateS256Hash(codeVerifier);
const redirectUri = authCodeRequestRedirectUrl(serverUrl);

Check warning on line 142 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L139-L142

Added lines #L139 - L142 were not covered by tests

const requestData = {

Check warning on line 144 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L144

Added line #L144 was not covered by tests
codeVerifier,
landingPath,
redirectUri,
};

window.sessionStorage.setItem(authCodeRequestStorageKey(requestId), JSON.stringify(requestData));

Check warning on line 150 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L150

Added line #L150 was not covered by tests

const params = {

Check warning on line 152 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L152

Added line #L152 was not covered by tests
response_type: 'code',
client_id: 'cspace-ui',
scope: 'cspace.full',
redirect_uri: redirectUri,
state: requestId,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
tid: config.tenantId,
};

const queryString = qs.stringify(params);
const url = `${serverUrl}/cspace-services/oauth2/authorize?${queryString}`;

Check warning on line 164 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L163-L164

Added lines #L163 - L164 were not covered by tests

dispatch({

Check warning on line 166 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L166

Added line #L166 was not covered by tests
type: AUTH_CODE_URL_CREATED,
payload: url,
});

return url;

Check warning on line 171 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L171

Added line #L171 was not covered by tests
};

/**
* Log in, using either the saved user or an authorization code.
*
* @param {*} config
* @param {*} authCode The authorization code. If undefined, the stored user will be used.
* @param {*} requestData The data that was used to retrieve the authorization code.
* @returns
*/
export const login = (config, authCode, authCodeRequestData = {}) => (dispatch, getState) => {
const prevUsername = getUserUsername(getState());

dispatch(savePrefs());

dispatch({
type: LOGIN_STARTED,
meta: {
username,
},
});

return dispatch(renewAuth(config, username, password))
let username;

return dispatch(renewAuth(config, authCode, authCodeRequestData))
.then((loggedInUsername) => {
username = loggedInUsername;

return Promise.resolve();
})
.then(() => dispatch(loadPrefs(config, username)))
.then(() => dispatch(readAuthVocabs(config)))
.then(() => dispatch({
type: LOGIN_FULFILLED,
meta: {
landingPath: authCodeRequestData.landingPath,
prevUsername,
username,
},
}))
.catch((error) => dispatch({
type: LOGIN_REJECTED,
payload: error,
meta: {
username,
},
}));
};

/**
* Log in using a fulfilled authorization code request.
*
* @param {*} config
* @param {*} authCodeRequestId
* @param {*} authCode
* @returns
*/
export const loginWithAuthCodeRequest = (
config,
authCodeRequestId,
authCode,
) => async (dispatch) => {
const storageKey = authCodeRequestStorageKey(authCodeRequestId);
const authCodeRequestDataJson = window.sessionStorage.getItem(storageKey);

Check warning on line 229 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L227-L229

Added lines #L227 - L229 were not covered by tests

window.sessionStorage.removeItem(storageKey);

Check warning on line 231 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L231

Added line #L231 was not covered by tests

if (!authCodeRequestDataJson) {
const error = new Error();
error.code = ERR_AUTH_CODE_REQUEST_NOT_FOUND;

Check warning on line 235 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L233-L235

Added lines #L233 - L235 were not covered by tests

return dispatch({

Check warning on line 237 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L237

Added line #L237 was not covered by tests
type: LOGIN_REJECTED,
payload: error,
});
}

const authCodeRequestData = JSON.parse(authCodeRequestDataJson);

Check warning on line 243 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L243

Added line #L243 was not covered by tests

return dispatch(login(config, authCode, authCodeRequestData));

Check warning on line 245 in src/actions/login.js

View check run for this annotation

Codecov / codecov/patch

src/actions/login.js#L245

Added line #L245 was not covered by tests
};
2 changes: 1 addition & 1 deletion src/actions/logout.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const logout = () => (dispatch) => {

dispatch(savePrefs());

return getSession().logout()
return getSession().logout(false)
.then((response) => dispatch({
type: LOGOUT_FULFILLED,
payload: response,
Expand Down
Loading

0 comments on commit ecef118

Please sign in to comment.