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

STCLI-221 handle access-control via cookies #335

Merged
merged 9 commits into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion .github/workflows/build-npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit'
JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/'
OKAPI_PULL: '{ "urls" : [ "https://folio-registry.dev.folio.org" ] }'
SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info'
SQ_LCOV_REPORT: 'artifacts/coverage/lcov.info'
SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js'

runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
NODEJS_VERSION: '18'
JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit'
JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/'
SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info'
SQ_LCOV_REPORT: 'artifacts/coverage/lcov.info'
SQ_EXCLUSIONS: '**/platform/alias-service.js,**/docs/**,**/node_modules/**,**/examples/**,**/artifacts/**,**/ci/**,Jenkinsfile,**/LICENSE,**/*.css,**/*.md,**/*.json,**/tests/**,**/stories/*.js,**/test/**,**/.stories.js,**/resources/bigtest/interactors/**,**/resources/bigtest/network/**,**/*-test.js,**/*.test.js,**/*-spec.js,**/karma.conf.js,**/jest.config.js'

runs-on: ubuntu-latest
Expand Down
22 changes: 22 additions & 0 deletions lib/commands/okapi/cookies.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const importLazy = require('import-lazy')(require);

const AuthService = importLazy('../../okapi/auth-service');

function viewCookiesCommand() {
const authService = new AuthService();

authService.getAccessCookie().then((token) => {
console.log(token);
});

authService.getRefreshCookie().then((token) => {
console.log(token);
});
}

module.exports = {
command: 'cookies',
describe: 'Display the stored cookies',
builder: yargs => yargs.example('$0 okapi cookies', 'Display the stored cookies'),
handler: viewCookiesCommand,
};
2 changes: 1 addition & 1 deletion lib/create-app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const semver = require('semver');
const pascalCase = require('just-pascal-case');
const simpleGit = require('simple-git');
const { templates } = require('./environment/inventory');
const { version: currentCLIVersion } = require('../package.json');
const { version: currentCLIVersion } = require('../package');

const COOKIECUTTER_PREFIX = '__';

Expand Down
2 changes: 1 addition & 1 deletion lib/doc/generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
const fs = require('fs');
const path = require('path');
const { version } = require('../../package.json');
const { version } = require('../../package');
const logger = require('../cli/logger')('docs');
const { gatherCommands } = require('./yargs-help-parser');
const { generateToc, generateMarkdown } = require('./commands-to-markdown');
Expand Down
2 changes: 1 addition & 1 deletion lib/environment/development.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const yarn = require('../yarn');
const { allModules, allModulesAsFlatArray, toFolioName } = require('./inventory');
const AliasService = require('../platform/alias-service');
const logger = require('../cli/logger')();
const { version: currentCLIVersion } = require('../../package.json');
const { version: currentCLIVersion } = require('../../package');

// Compare a list of module names against those known to be valid
function validateModules(theModules) {
Expand Down
66 changes: 61 additions & 5 deletions lib/okapi/auth-service.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,85 @@
const tough = require('tough-cookie');
const TokenStorage = require('./token-storage');

// Logs into Okapi and persists token for subsequent use
module.exports = class AuthService {
accessCookie = 'folioAccessToken';
refreshCookie = 'folioRefreshToken';

constructor(okapiRepository) {
this.okapi = okapiRepository;
this.tokenStorage = new TokenStorage();
}

/**
* login
* Send a login request, then look for cookies (default) or an
* x-okapi-token header in the response and return it.
*
* @param {string} username
* @param {string} password
* @returns
*/
login(username, password) {
this.tokenStorage.clearToken();
this.tokenStorage.clearAccessCookie();
this.tokenStorage.clearRefreshCookie();

return this.okapi.authn.login(username, password)
.then((response) => {
const token = response.headers.get('x-okapi-token');
this.tokenStorage.setToken(token);
return token;
return this.saveTokens(response);
});
}

/**
* saveTokens
* Extract and store rt/at cookies and the x-okapi-token header
* from an HTTP response.
* @param {fetch response} response
*/
saveTokens(response) {
const Cookie = tough.Cookie;
const cookieHeaders = response.headers.raw()['set-cookie'];
let cookies = null;
if (Array.isArray(cookieHeaders)) {
cookies = cookieHeaders.map(Cookie.parse);
} else {
cookies = [Cookie.parse(cookieHeaders)];
}
if (cookies && cookies.length > 0) {
cookies.forEach(c => {
if (c.key === this.accessCookie) {
this.tokenStorage.setAccessCookie(c);
}

if (c.key === this.refreshCookie) {
this.tokenStorage.setRefreshCookie(c);
}
});
}

const token = response.headers.get('x-okapi-token');
if (token) {
this.tokenStorage.setToken(token);
}
}

logout() {
this.tokenStorage.clearToken();
this.tokenStorage.clearAccessCookie();
this.tokenStorage.clearRefreshCookie();
return Promise.resolve();
}

getAccessCookie() {
return Promise.resolve(this.tokenStorage.getAccessCookie());
}

getRefreshCookie() {
return Promise.resolve(this.tokenStorage.getRefreshCookie());
}

getToken() {
const token = this.tokenStorage.getToken();
return Promise.resolve(token);
return Promise.resolve(this.tokenStorage.getToken());
}
};
37 changes: 37 additions & 0 deletions lib/okapi/okapi-client-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const fetch = require('minipass-fetch');
const OkapiError = require('./okapi-error');

const logger = require('../cli/logger')('okapi');


// Ensures the operation return with a 2xx status code
function ensureOk(response) {
logger.log(`<--- ${response.status} ${response.statusText}`);
if (response.ok) {
return response;
}
return response.text().then((message) => {
throw new OkapiError(response, message);
});
}

function optionsHeaders(options) {
return Object.entries(options.headers || {}).map(([k, v]) => `-H '${k}: ${v}'`).join(' ');
}

function optionsBody(options) {
return options.body ? `-d ${JSON.stringify(options.body)}` : '';
}

// Wraps fetch to capture request/response for logging
function okapiFetch(resource, options) {
logger.log(`---> curl -X${options.method} ${optionsHeaders(options)} ${resource} ${optionsBody(options)}`);
return fetch(resource, options).then(ensureOk);
}

module.exports = {
ensureOk,
optionsHeaders,
optionsBody,
okapiFetch,
};
114 changes: 82 additions & 32 deletions lib/okapi/okapi-client.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,10 @@
const fetch = require('node-fetch-npm');
const url = require('url');

const logger = require('../cli/logger')('okapi');
const TokenStorage = require('./token-storage');
const OkapiError = require('./okapi-error');

// Ensures the operation return with a 2xx status code
function ensureOk(response) {
logger.log(`<--- ${response.status} ${response.statusText}`);
if (response.ok) {
return response;
}
return response.text().then((message) => {
throw new OkapiError(response, message);
});
}

function optionsHeaders(options) {
return Object.entries(options.headers || {}).map(([k, v]) => `-H '${k}: ${v}'`).join(' ');
}

function optionsBody(options) {
return options.body ? `-d ${JSON.stringify(options.body)}` : '';
}
const AuthService = require('./auth-service');

// Wraps fetch to capture request/response for logging
function okapiFetch(resource, options) {
logger.log(`---> curl -X${options.method} ${optionsHeaders(options)} ${resource} ${optionsBody(options)}`);
return fetch(resource, options).then(ensureOk);
}
const { okapiFetch } = require('./okapi-client-helper');

module.exports = class OkapiClient {
constructor(okapi, tenant) {
Expand All @@ -38,41 +15,107 @@ module.exports = class OkapiClient {

get(resource, okapiOptions) {
const options = { method: 'GET' };
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
return this._options(options, okapiOptions).then(opt => {
return okapiFetch(this._url(resource), opt);
});
}

post(resource, body, okapiOptions) {
const options = {
method: 'POST',
body: JSON.stringify(body),
};
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
return this._options(options, okapiOptions).then(opt => {
return okapiFetch(this._url(resource), opt);
});
}

put(resource, body, okapiOptions) {
const options = {
method: 'PUT',
body: JSON.stringify(body),
};
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
return this._options(options, okapiOptions).then(opt => {
return okapiFetch(this._url(resource), opt);
});
}

delete(resource, okapiOptions) {
const options = { method: 'DELETE' };
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
return this._options(options, okapiOptions).then(opt => {
return okapiFetch(this._url(resource), opt);
});
}

_url(resource) {
const { path } = url.parse(resource);
return url.resolve(this.okapiBase, path);
}

/**
* _exchangesToken
* Exchange a refresh token, storing the new AT and RT cookies
* in the AuthService and returning the AT for immediate use.
* @returns Promise resolving to an AT shaped like tough-cookie.Cookie.
*/
_exchangeToken(fetchHandler) {
logger.log('---> refresh token exchange');
const rt = this.tokenStorage.getRefreshCookie();
if (new Date(rt.expires).getTime() > new Date().getTime()) {
const options = {
credentials: 'include',
method: 'POST',
mode: 'cors',
};

const headers = {
'content-type': 'application/json',
'x-okapi-tenant': this.tenant,
'cookie': `${rt.key}=${rt.value}`,
};

return fetchHandler(this._url('authn/refresh'), { ...options, headers }).then(response => {
const as = new AuthService();
as.saveTokens(response);
return as.getAccessCookie();
});
}

throw new Error(`Refresh token expired at ${rt.expires}`);
}

/**
*
* @returns Promise
*/
_accessToken() {
const at = this.tokenStorage.getAccessCookie();
if (at) {
if (((new Date(at.expires)).getTime()) > (new Date().getTime())) {
return Promise.resolve(at);
}

return this._exchangeToken(okapiFetch);
}

return Promise.resolve();
}

/**
* _options
* Configure request options and headers. Returns a promise because
* the access-token cookie may need to be exchanged for a fresh one,
* an async process.
* @param {*} options
* @param {*} okapiOverrides
* @returns Promise resolving to an access token
*/
_options(options, okapiOverrides) {
const okapiOptions = {
tenant: this.tenant,
token: this.tokenStorage.getToken(),
...okapiOverrides,
};
Object.assign(okapiOptions, okapiOverrides);

const headers = {
'content-type': 'application/json',
Expand All @@ -84,6 +127,13 @@ module.exports = class OkapiClient {
headers['x-okapi-tenant'] = okapiOptions.tenant;
}

return Object.assign({}, options, { headers });
return this._accessToken()
.then(at => {
if (at) {
headers.cookie = `${at.key}=${at.value}`;
}

return { ...options, headers };
});
}
};
2 changes: 1 addition & 1 deletion lib/okapi/okapi-repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const noTenantNoToken = {
let okapiClient = {};

function login(username, password) {
return okapiClient.post('/authn/login', { username, password });
return okapiClient.post('/authn/login-with-expiry', { username, password });
}

function addModuleDescriptor(moduleDescriptor) {
Expand Down
Loading