Skip to content

Commit

Permalink
Use authentication code grant instead of password grant. (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
ray-lee committed Sep 20, 2023
1 parent 2ea97b1 commit 4338088
Show file tree
Hide file tree
Showing 9 changed files with 138 additions and 90 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Change Log

## v2.0.0

v2.0.0 adds support for the OAuth 2 authorization code grant, used by CollectionSpace 8.0.

### Breaking Changes

- The session login method now issues a request for a token using the OAuth 2 authorization code grant, instead of the password grant. This requires a CollectionSpace 8.0 server. A login attempt to an older CollectionSpace server will fail.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "cspace-client",
"version": "1.1.8",
"version": "2.0.0-rc.1",
"description": "CollectionSpace client for browsers and Node.js",
"author": "Ray Lee <ray.lee@lyrasis.org>",
"license": "ECL-2.0",
Expand Down
100 changes: 48 additions & 52 deletions src/session.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,12 @@
/* global window */

import cspace from 'cspace-api';
import urljoin from 'url-join';
import tokenStore from './tokenStore';
import { parseJwt } from './tokenHelpers';

const defaultSessionConfig = {
username: '',
password: '',
};

const base64Encode = (value) => {
if (typeof value === 'undefined' || value === null) {
return value;
}

if (typeof window !== 'undefined') {
return window.btoa(value);
}

return Buffer.from(value).toString('base64');
authCode: '',
codeVerifier: '',
redirectUri: '',
};

export default function session(sessionConfig) {
Expand All @@ -30,44 +18,52 @@ export default function session(sessionConfig) {
const authStore = tokenStore(config.clientId, config.url);

let authRequestPending = null;
let auth = authStore.fetch() || {};
let auth = {};

if (config.username) {
// The username for this session was specified, and differs from the stored auth user.
// Don't use the stored auth.
if (!config.authCode) {
// The auth code for this session wasn't specified. Use the stored auth, if any.

if (config.username !== auth.username) {
auth = {};
}
} else if (auth.username) {
// The username for this session was not specified. Use the stored auth user, if one exists.

config.username = auth.username;
auth = authStore.fetch() || {};
}

const cs = cspace({
url: urljoin(config.url, 'cspace-services'),
});

const csAuth = cspace({
url: urljoin(config.url, 'cspace-services/oauth'),
username: config.clientId,
password: config.clientSecret,
url: urljoin(config.url, 'cspace-services/oauth2'),
type: 'application/x-www-form-urlencoded',
...(config.clientSecret ? {
username: config.clientId,
password: config.clientSecret,
} : undefined),
});

const storeToken = (response) => {
const {
access_token: accessToken,
refresh_token: refreshToken,
} = response.data;

const jwt = parseJwt(accessToken);

const {
sub: username,
} = jwt;

auth = {
username: config.username,
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
username,
accessToken,
refreshToken,
};

authStore.store(auth);

// We have tokens, so the user password can be discarded.
// We have tokens, so the authoriztion code, code verifier, and client secret can be discarded.

delete config.password;
delete config.authCode;
delete config.codeVerifier;
delete config.clientSecret;

return response;
};
Expand All @@ -89,31 +85,30 @@ export default function session(sessionConfig) {
};

const login = () => authRequest({
grant_type: 'password',
username: config.username,
password: base64Encode(config.password),
grant_type: 'authorization_code',
code: config.authCode,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: config.codeVerifier,
});

const refresh = () => authRequest({
grant_type: 'refresh_token',
refresh_token: auth.refreshToken,
});

const logout = () => new Promise((resolve) => {
// Log out may in the future require an async call to the REST API (for example, to revoke
// tokens immediately). Currently it's a client-side only operation that can be done
// synchronously, but to be consistent with a future async operation, we'll simulate it with
// setTimeout.

setTimeout(() => {
delete config.username;
delete config.password;
const logout = (serviceLogout = true) => new Promise((resolve) => {
const serviceLogoutPromise = serviceLogout
? cs.create('logout')
: Promise.resolve();

auth = {};
authStore.clear();
return serviceLogoutPromise
.finally(() => {
auth = {};
authStore.clear();

resolve({});
});
resolve({});
});
});

const tokenizeRequest = (requestConfig) => {
Expand Down Expand Up @@ -151,14 +146,15 @@ export default function session(sessionConfig) {
);

return {
config() {
config: () => {
const configCopy = { ...config };

delete configCopy.clientSecret;
delete configCopy.password;

return configCopy;
},
username: () => (auth ? auth.username : null),
login,
logout,
create: tokenized('create'),
Expand Down
24 changes: 24 additions & 0 deletions src/tokenHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,27 @@ export const isLocalStorageAvailable = () => {
return false;
}
};

const base64Decode = (encoded) => {
if (typeof window !== 'undefined') {
// We're in a browser.

return window.atob(encoded)
.split('')
// eslint-disable-next-line prefer-template
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('');
}

// We're in Node.

return Buffer.from(encoded, 'base64').toString('utf-8');
};

export const parseJwt = (token) => {
const urlSafeBase64 = token.split('.')[1];
const base64 = urlSafeBase64.replace(/-/g, '+').replace(/_/g, '/');
const json = decodeURIComponent(base64Decode(base64));

return JSON.parse(json);
};
17 changes: 16 additions & 1 deletion test/integration/crud.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
/* global globalThis */

// These integration tests are disabled as of version 2.0.0, because they require authenticating as
// a reader and an administrator. Since we now use the OAuth authorization code grant instead of
// the password grant, user authentication is no longer the responsibility (or know-how) of this
// package. These tests should be done at a higher level, probably as Selenium tests of the CSpace
// UI.
//
// These tests could potentially be restored if we add support for long-lived API tokens associated
// with users.

import chai from 'chai';
import chaiAsPromised from 'chai-as-promised';
import client from '../../src/client';
Expand Down Expand Up @@ -29,7 +38,13 @@ const readerSessionConfig = {
password: 'reader',
};

describe(`crud operations on ${clientConfig.url}`, function suite() {
describe('Disabled tests', () => {
it('should do nothing', () => {
// This is just here to keep npm test happy, since all the actual tests are disabled (skipped).
});
});

describe.skip(`crud operations on ${clientConfig.url}`, function suite() {
this.timeout(20000);

const cspace = client(clientConfig);
Expand Down
26 changes: 9 additions & 17 deletions test/specs/session.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,23 @@ describe('session', () => {

it('should set default options', () => {
session().config().should.deep.equal({
username: '',
authCode: '',
codeVerifier: '',
redirectUri: '',
});
});

it('should override default options with passed options', () => {
const config = {
username: 'user@collectionspace.org',
password: 'secret',
authCode: 'abcd',
codeVerifier: '123',
redirectUri: '/authorized',
};

session(config).config().should.deep.equal({
username: 'user@collectionspace.org',
});
});
});

describe('#config()', () => {
it('should omit the password', () => {
const config = {
username: 'user@collectionspace.org',
password: 'secret',
};

session(config).config().should.deep.equal({
username: 'user@collectionspace.org',
authCode: 'abcd',
codeVerifier: '123',
redirectUri: '/authorized',
});
});
});
Expand Down
12 changes: 7 additions & 5 deletions test/specs/tokenManagement.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ const clientConfig = {
};

const sessionConfig = {
username: 'user@collectionspace.org',
password: 'secret',
authCode: 'abcd',
clientId: 'cpace-ui',
codeVerifier: '123',
redirectUri: '/authorized',
};

let accessToken;
Expand Down Expand Up @@ -42,17 +44,17 @@ describe(`token management on ${clientConfig.url}`, function suite() {
.be.rejected
));

it('reuses the stored token in a new session with no user', () => {
it('reuses the stored token in a new session with no auth code', () => {
const newSession = cspace.session();

return newSession.read('something').should.eventually
.be.fulfilled
.and.have.deep.property('data.presentedToken', accessToken);
});

it('does not reuse the stored token in a new session with a different user', () => {
it('does not reuse the stored token in a new session with an auth code', () => {
const newSession = cspace.session({
username: 'somebody@collectionspace.org',
authCode: 'xyz',
});

return newSession.read('something').should.eventually
Expand Down
34 changes: 22 additions & 12 deletions test/stubs/cspaceServerMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ const sendJson = require('send-data/json');
const delay = 500;

/**
* Counter to ensure uniqueness of tokens on each grant.
* The access token to return. This is a valid JWT token, without a signature.
*/
const jwtTokenBase = 'eyJraWQiOiJlZWY2OGZmMC0yNWFjLTRkYzUtYTY3NS04ZjIzY2FiYzJmODciLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhZG1pbkBjb3JlLmNvbGxlY3Rpb25zcGFjZS5vcmciLCJhdWQiOiJjc3BhY2UtdWkiLCJuYmYiOjE2OTQ4NDA5ODUsInNjb3BlIjpbImNzcGFjZS5mdWxsIl0sImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6ODE4MC9jc3BhY2Utc2VydmljZXMiLCJleHAiOjE2OTQ4NDEwMTUsImlhdCI6MTY5NDg0MDk4NX0';

/**
* Counter to ensure uniqueness of tokens on each grant. This is appended to tokenBase as a
* "signature".
*/
let tokenNum = 0;

Expand All @@ -25,27 +31,31 @@ let tokenNum = 0;
* for example by adding it to the npm test script in package.json.
*/
module.exports = function cspaceServerMiddleware(req, res, next) {
if (req.method === 'POST' && req.url === '/cspace-services/oauth/token') {
// Simulate an OAuth2 password credentials grant or refresh token grant on a POST to the token
if (req.method === 'POST' && req.url === '/cspace-services/oauth2/token') {
// Simulate an OAuth2 authorization code grant or refresh token grant on a POST to the token
// endpoint.

// If the grant type is 'password', a token is granted as long as the username and password
// are truthy. If the grant type is 'refresh_token', a token is granted as long as the refresh
// token is truthy. An artificial delay is introduced before replying, in order to make testing
// of multiple simultaneous requests easy.
// If the grant type is 'authorization_code', a token is granted as long as the code, code
// verifier, client id, and redirect URI are truthy. If the grant type is 'refresh_token', a
// token is granted as long as the refresh token is truthy. An artificial delay is introduced
// before replying, in order to make testing of multiple simultaneous requests easy.

// The granted tokens are returned along with the request body so that it can be verified.

let accept = false;
const grantType = req.body.grant_type;

if (grantType === 'password') {
if (grantType === 'authorization_code') {
/* eslint-disable camelcase */
const {
username,
password,
client_id,
code,
code_verifier,
redirect_uri,
} = req.body;

accept = username && password;
accept = client_id && code && code_verifier && redirect_uri;
/* eslint-enable camelcase */
} else if (grantType === 'refresh_token') {
const token = req.body.refresh_token;

Expand All @@ -59,7 +69,7 @@ module.exports = function cspaceServerMiddleware(req, res, next) {
sendJson(req, res, {
statusCode: 200,
body: {
access_token: `access_${tokenNum}`,
access_token: `${jwtTokenBase}.${tokenNum}`,
refresh_token: `refresh_${tokenNum}`,
request_body: req.body,
},
Expand Down

0 comments on commit 4338088

Please sign in to comment.