Skip to content

Commit 23509fc

Browse files
authored
STCLI-221 handle access-control via cookies (#335)
Handle access-control via access-token and refresh-token cookies instead of storing and passing the `X-Okapi-Token` HTTP request header. The AT is transparently refreshed when it expires as long as the RT is still valid. Replace node-fetch-npm with minipass-fetch, because it was there. Refs STCLI-221, FOLIO-3627, STCLI-214
1 parent a4f1e7a commit 23509fc

18 files changed

+557
-62
lines changed

.github/workflows/build-npm-release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit'
3333
JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/'
3434
OKAPI_PULL: '{ "urls" : [ "https://folio-registry.dev.folio.org" ] }'
35-
SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info'
35+
SQ_LCOV_REPORT: 'artifacts/coverage/lcov.info'
3636
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'
3737

3838
runs-on: ubuntu-latest

.github/workflows/build-npm.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
NODEJS_VERSION: '18'
2929
JEST_JUNIT_OUTPUT_DIR: 'artifacts/jest-junit'
3030
JEST_COVERAGE_REPORT_DIR: 'artifacts/coverage/lcov-report/'
31-
SQ_LCOV_REPORT: 'artifacts/coverage-jest/lcov.info'
31+
SQ_LCOV_REPORT: 'artifacts/coverage/lcov.info'
3232
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'
3333

3434
runs-on: ubuntu-latest

lib/commands/okapi/cookies.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const importLazy = require('import-lazy')(require);
2+
3+
const AuthService = importLazy('../../okapi/auth-service');
4+
5+
function viewCookiesCommand() {
6+
const authService = new AuthService();
7+
8+
authService.getAccessCookie().then((token) => {
9+
console.log(token);
10+
});
11+
12+
authService.getRefreshCookie().then((token) => {
13+
console.log(token);
14+
});
15+
}
16+
17+
module.exports = {
18+
command: 'cookies',
19+
describe: 'Display the stored cookies',
20+
builder: yargs => yargs.example('$0 okapi cookies', 'Display the stored cookies'),
21+
handler: viewCookiesCommand,
22+
};

lib/create-app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const semver = require('semver');
55
const pascalCase = require('just-pascal-case');
66
const simpleGit = require('simple-git');
77
const { templates } = require('./environment/inventory');
8-
const { version: currentCLIVersion } = require('../package.json');
8+
const { version: currentCLIVersion } = require('../package');
99

1010
const COOKIECUTTER_PREFIX = '__';
1111

lib/doc/generator.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
const fs = require('fs');
55
const path = require('path');
6-
const { version } = require('../../package.json');
6+
const { version } = require('../../package');
77
const logger = require('../cli/logger')('docs');
88
const { gatherCommands } = require('./yargs-help-parser');
99
const { generateToc, generateMarkdown } = require('./commands-to-markdown');

lib/environment/development.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const yarn = require('../yarn');
88
const { allModules, allModulesAsFlatArray, toFolioName } = require('./inventory');
99
const AliasService = require('../platform/alias-service');
1010
const logger = require('../cli/logger')();
11-
const { version: currentCLIVersion } = require('../../package.json');
11+
const { version: currentCLIVersion } = require('../../package');
1212

1313
// Compare a list of module names against those known to be valid
1414
function validateModules(theModules) {

lib/okapi/auth-service.js

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,85 @@
1+
const tough = require('tough-cookie');
12
const TokenStorage = require('./token-storage');
23

34
// Logs into Okapi and persists token for subsequent use
45
module.exports = class AuthService {
6+
accessCookie = 'folioAccessToken';
7+
refreshCookie = 'folioRefreshToken';
8+
59
constructor(okapiRepository) {
610
this.okapi = okapiRepository;
711
this.tokenStorage = new TokenStorage();
812
}
913

14+
/**
15+
* login
16+
* Send a login request, then look for cookies (default) or an
17+
* x-okapi-token header in the response and return it.
18+
*
19+
* @param {string} username
20+
* @param {string} password
21+
* @returns
22+
*/
1023
login(username, password) {
1124
this.tokenStorage.clearToken();
25+
this.tokenStorage.clearAccessCookie();
26+
this.tokenStorage.clearRefreshCookie();
27+
1228
return this.okapi.authn.login(username, password)
1329
.then((response) => {
14-
const token = response.headers.get('x-okapi-token');
15-
this.tokenStorage.setToken(token);
16-
return token;
30+
return this.saveTokens(response);
1731
});
1832
}
1933

34+
/**
35+
* saveTokens
36+
* Extract and store rt/at cookies and the x-okapi-token header
37+
* from an HTTP response.
38+
* @param {fetch response} response
39+
*/
40+
saveTokens(response) {
41+
const Cookie = tough.Cookie;
42+
const cookieHeaders = response.headers.raw()['set-cookie'];
43+
let cookies = null;
44+
if (Array.isArray(cookieHeaders)) {
45+
cookies = cookieHeaders.map(Cookie.parse);
46+
} else {
47+
cookies = [Cookie.parse(cookieHeaders)];
48+
}
49+
if (cookies && cookies.length > 0) {
50+
cookies.forEach(c => {
51+
if (c.key === this.accessCookie) {
52+
this.tokenStorage.setAccessCookie(c);
53+
}
54+
55+
if (c.key === this.refreshCookie) {
56+
this.tokenStorage.setRefreshCookie(c);
57+
}
58+
});
59+
}
60+
61+
const token = response.headers.get('x-okapi-token');
62+
if (token) {
63+
this.tokenStorage.setToken(token);
64+
}
65+
}
66+
2067
logout() {
2168
this.tokenStorage.clearToken();
69+
this.tokenStorage.clearAccessCookie();
70+
this.tokenStorage.clearRefreshCookie();
2271
return Promise.resolve();
2372
}
2473

74+
getAccessCookie() {
75+
return Promise.resolve(this.tokenStorage.getAccessCookie());
76+
}
77+
78+
getRefreshCookie() {
79+
return Promise.resolve(this.tokenStorage.getRefreshCookie());
80+
}
81+
2582
getToken() {
26-
const token = this.tokenStorage.getToken();
27-
return Promise.resolve(token);
83+
return Promise.resolve(this.tokenStorage.getToken());
2884
}
2985
};

lib/okapi/okapi-client-helper.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const fetch = require('minipass-fetch');
2+
const OkapiError = require('./okapi-error');
3+
4+
const logger = require('../cli/logger')('okapi');
5+
6+
7+
// Ensures the operation return with a 2xx status code
8+
function ensureOk(response) {
9+
logger.log(`<--- ${response.status} ${response.statusText}`);
10+
if (response.ok) {
11+
return response;
12+
}
13+
return response.text().then((message) => {
14+
throw new OkapiError(response, message);
15+
});
16+
}
17+
18+
function optionsHeaders(options) {
19+
return Object.entries(options.headers || {}).map(([k, v]) => `-H '${k}: ${v}'`).join(' ');
20+
}
21+
22+
function optionsBody(options) {
23+
return options.body ? `-d ${JSON.stringify(options.body)}` : '';
24+
}
25+
26+
// Wraps fetch to capture request/response for logging
27+
function okapiFetch(resource, options) {
28+
logger.log(`---> curl -X${options.method} ${optionsHeaders(options)} ${resource} ${optionsBody(options)}`);
29+
return fetch(resource, options).then(ensureOk);
30+
}
31+
32+
module.exports = {
33+
ensureOk,
34+
optionsHeaders,
35+
optionsBody,
36+
okapiFetch,
37+
};

lib/okapi/okapi-client.js

Lines changed: 82 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,10 @@
1-
const fetch = require('node-fetch-npm');
21
const url = require('url');
2+
33
const logger = require('../cli/logger')('okapi');
44
const TokenStorage = require('./token-storage');
5-
const OkapiError = require('./okapi-error');
6-
7-
// Ensures the operation return with a 2xx status code
8-
function ensureOk(response) {
9-
logger.log(`<--- ${response.status} ${response.statusText}`);
10-
if (response.ok) {
11-
return response;
12-
}
13-
return response.text().then((message) => {
14-
throw new OkapiError(response, message);
15-
});
16-
}
17-
18-
function optionsHeaders(options) {
19-
return Object.entries(options.headers || {}).map(([k, v]) => `-H '${k}: ${v}'`).join(' ');
20-
}
21-
22-
function optionsBody(options) {
23-
return options.body ? `-d ${JSON.stringify(options.body)}` : '';
24-
}
5+
const AuthService = require('./auth-service');
256

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

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

3916
get(resource, okapiOptions) {
4017
const options = { method: 'GET' };
41-
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
18+
return this._options(options, okapiOptions).then(opt => {
19+
return okapiFetch(this._url(resource), opt);
20+
});
4221
}
4322

4423
post(resource, body, okapiOptions) {
4524
const options = {
4625
method: 'POST',
4726
body: JSON.stringify(body),
4827
};
49-
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
28+
return this._options(options, okapiOptions).then(opt => {
29+
return okapiFetch(this._url(resource), opt);
30+
});
5031
}
5132

5233
put(resource, body, okapiOptions) {
5334
const options = {
5435
method: 'PUT',
5536
body: JSON.stringify(body),
5637
};
57-
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
38+
return this._options(options, okapiOptions).then(opt => {
39+
return okapiFetch(this._url(resource), opt);
40+
});
5841
}
5942

6043
delete(resource, okapiOptions) {
6144
const options = { method: 'DELETE' };
62-
return okapiFetch(this._url(resource), this._options(options, okapiOptions));
45+
return this._options(options, okapiOptions).then(opt => {
46+
return okapiFetch(this._url(resource), opt);
47+
});
6348
}
6449

6550
_url(resource) {
6651
const { path } = url.parse(resource);
6752
return url.resolve(this.okapiBase, path);
6853
}
6954

55+
/**
56+
* _exchangesToken
57+
* Exchange a refresh token, storing the new AT and RT cookies
58+
* in the AuthService and returning the AT for immediate use.
59+
* @returns Promise resolving to an AT shaped like tough-cookie.Cookie.
60+
*/
61+
_exchangeToken(fetchHandler) {
62+
logger.log('---> refresh token exchange');
63+
const rt = this.tokenStorage.getRefreshCookie();
64+
if (new Date(rt.expires).getTime() > new Date().getTime()) {
65+
const options = {
66+
credentials: 'include',
67+
method: 'POST',
68+
mode: 'cors',
69+
};
70+
71+
const headers = {
72+
'content-type': 'application/json',
73+
'x-okapi-tenant': this.tenant,
74+
'cookie': `${rt.key}=${rt.value}`,
75+
};
76+
77+
return fetchHandler(this._url('authn/refresh'), { ...options, headers }).then(response => {
78+
const as = new AuthService();
79+
as.saveTokens(response);
80+
return as.getAccessCookie();
81+
});
82+
}
83+
84+
throw new Error(`Refresh token expired at ${rt.expires}`);
85+
}
86+
87+
/**
88+
*
89+
* @returns Promise
90+
*/
91+
_accessToken() {
92+
const at = this.tokenStorage.getAccessCookie();
93+
if (at) {
94+
if (((new Date(at.expires)).getTime()) > (new Date().getTime())) {
95+
return Promise.resolve(at);
96+
}
97+
98+
return this._exchangeToken(okapiFetch);
99+
}
100+
101+
return Promise.resolve();
102+
}
103+
104+
/**
105+
* _options
106+
* Configure request options and headers. Returns a promise because
107+
* the access-token cookie may need to be exchanged for a fresh one,
108+
* an async process.
109+
* @param {*} options
110+
* @param {*} okapiOverrides
111+
* @returns Promise resolving to an access token
112+
*/
70113
_options(options, okapiOverrides) {
71114
const okapiOptions = {
72115
tenant: this.tenant,
73116
token: this.tokenStorage.getToken(),
117+
...okapiOverrides,
74118
};
75-
Object.assign(okapiOptions, okapiOverrides);
76119

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

87-
return Object.assign({}, options, { headers });
130+
return this._accessToken()
131+
.then(at => {
132+
if (at) {
133+
headers.cookie = `${at.key}=${at.value}`;
134+
}
135+
136+
return { ...options, headers };
137+
});
88138
}
89139
};

lib/okapi/okapi-repository.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const noTenantNoToken = {
99
let okapiClient = {};
1010

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

1515
function addModuleDescriptor(moduleDescriptor) {

0 commit comments

Comments
 (0)