Skip to content

Commit 9562aa9

Browse files
authored
Merge pull request #267 from node-oauth/compliance/fix-scope
Compliance/fix scope
2 parents e01a5e4 + 77d00b2 commit 9562aa9

12 files changed

+94
-113
lines changed

lib/handlers/authenticate-handler.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const Request = require('../request');
1313
const Response = require('../response');
1414
const ServerError = require('../errors/server-error');
1515
const UnauthorizedRequestError = require('../errors/unauthorized-request-error');
16+
const { parseScope } = require('../utils/scope-util');
1617

1718
/**
1819
* Constructor.
@@ -46,7 +47,7 @@ class AuthenticateHandler {
4647
this.addAuthorizedScopesHeader = options.addAuthorizedScopesHeader;
4748
this.allowBearerTokensInQueryString = options.allowBearerTokensInQueryString;
4849
this.model = options.model;
49-
this.scope = options.scope;
50+
this.scope = parseScope(options.scope);
5051
}
5152

5253
/**

lib/handlers/token-handler.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,12 @@ class TokenHandler {
266266
updateSuccessResponse (response, tokenType) {
267267
response.body = tokenType.valueOf();
268268

269+
// for compliance reasons we rebuild the internal scope to be a string
270+
// https://datatracker.ietf.org/doc/html/rfc6749.html#section-5.1
271+
if (response.body.scope) {
272+
response.body.scope = response.body.scope.join(' ');
273+
}
274+
269275
response.set('Cache-Control', 'no-store');
270276
response.set('Pragma', 'no-cache');
271277
}

lib/utils/scope-util.js

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
const isFormat = require('@node-oauth/formats');
22
const InvalidScopeError = require('../errors/invalid-scope-error');
3+
const whiteSpace = /\s+/g;
34

45
module.exports = {
56
parseScope: function (requestedScope) {
6-
if (!isFormat.nqschar(requestedScope)) {
7+
if (requestedScope == null) {
8+
return undefined;
9+
}
10+
11+
if (typeof requestedScope !== 'string') {
712
throw new InvalidScopeError('Invalid parameter: `scope`');
813
}
914

10-
if (requestedScope == null) {
11-
return undefined;
15+
// XXX: this prevents spaced-only strings to become
16+
// treated as valid nqchar by making them empty strings
17+
requestedScope = requestedScope.trim();
18+
19+
if(!isFormat.nqschar(requestedScope)) {
20+
throw new InvalidScopeError('Invalid parameter: `scope`');
1221
}
1322

14-
return requestedScope.split(' ');
23+
return requestedScope.split(whiteSpace);
1524
}
1625
};

lib/validator/is.js

Lines changed: 0 additions & 90 deletions
This file was deleted.

test/compliance/client-credential-workflow_test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ describe('ClientCredentials Workflow Compliance (4.4)', function () {
9090
response.body.token_type.should.equal('Bearer');
9191
response.body.access_token.should.equal(token.accessToken);
9292
response.body.expires_in.should.be.a('number');
93-
response.body.scope.should.eql(['read', 'write']);
93+
response.body.scope.should.eql('read write');
9494
('refresh_token' in response.body).should.equal(false);
9595

9696
token.accessToken.should.be.a('string');

test/compliance/password-grant-type_test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ describe('PasswordGrantType Compliance', function () {
101101
response.body.access_token.should.equal(token.accessToken);
102102
response.body.refresh_token.should.equal(token.refreshToken);
103103
response.body.expires_in.should.be.a('number');
104-
response.body.scope.should.eql(['read', 'write']);
104+
response.body.scope.should.eql('read write');
105105

106106
token.accessToken.should.be.a('string');
107107
token.refreshToken.should.be.a('string');

test/compliance/refresh-token-grant-type_test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ describe('RefreshTokenGrantType Compliance', function () {
124124
refreshResponse.body.access_token.should.equal(token.accessToken);
125125
refreshResponse.body.refresh_token.should.equal(token.refreshToken);
126126
refreshResponse.body.expires_in.should.be.a('number');
127-
refreshResponse.body.scope.should.eql(['read', 'write']);
127+
refreshResponse.body.scope.should.eql('read write');
128128

129129
token.accessToken.should.be.a('string');
130130
token.refreshToken.should.be.a('string');
@@ -223,7 +223,7 @@ describe('RefreshTokenGrantType Compliance', function () {
223223
refreshResponse.body.access_token.should.equal(token.accessToken);
224224
refreshResponse.body.refresh_token.should.equal(token.refreshToken);
225225
refreshResponse.body.expires_in.should.be.a('number');
226-
refreshResponse.body.scope.should.eql(['read']);
226+
refreshResponse.body.scope.should.eql('read');
227227
});
228228
});
229229
});

test/helpers/model.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ function createModel (db) {
1010
}
1111

1212
async function saveToken (token, client, user) {
13+
if (token.scope && !Array.isArray(token.scope)) {
14+
throw new Error('Scope should internally be an array');
15+
}
1316
const meta = {
1417
clientId: client.id,
1518
userId: user.id,
@@ -38,7 +41,9 @@ function createModel (db) {
3841
if (!meta) {
3942
return false;
4043
}
41-
44+
if (meta.scope && !Array.isArray(meta.scope)) {
45+
throw new Error('Scope should internally be an array');
46+
}
4247
return {
4348
accessToken,
4449
accessTokenExpiresAt: meta.accessTokenExpiresAt,
@@ -54,7 +59,9 @@ function createModel (db) {
5459
if (!meta) {
5560
return false;
5661
}
57-
62+
if (meta.scope && !Array.isArray(meta.scope)) {
63+
throw new Error('Scope should internally be an array');
64+
}
5865
return {
5966
refreshToken,
6067
refreshTokenExpiresAt: meta.refreshTokenExpiresAt,
@@ -71,6 +78,9 @@ function createModel (db) {
7178
}
7279

7380
async function verifyScope (token, scope) {
81+
if (!Array.isArray(scope)) {
82+
throw new Error('Scope should internally be an array');
83+
}
7484
return scope.every(s => scopes.includes(s));
7585
}
7686

test/integration/handlers/authenticate-handler_test.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('AuthenticateHandler integration', function() {
9393
addAcceptedScopesHeader: true,
9494
addAuthorizedScopesHeader: true,
9595
model: model,
96-
scope: ['foobar']
96+
scope: 'foobar'
9797
});
9898

9999
grantType.scope.should.eql(['foobar']);
@@ -254,7 +254,7 @@ describe('AuthenticateHandler integration', function() {
254254
return true;
255255
}
256256
};
257-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] });
257+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' });
258258
const request = new Request({
259259
body: {},
260260
headers: { 'Authorization': 'Bearer foo' },
@@ -522,7 +522,7 @@ describe('AuthenticateHandler integration', function() {
522522
return false;
523523
}
524524
};
525-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] });
525+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' });
526526

527527
return handler.verifyScope(['foo'])
528528
.then(should.fail)
@@ -539,7 +539,7 @@ describe('AuthenticateHandler integration', function() {
539539
return true;
540540
}
541541
};
542-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] });
542+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' });
543543

544544
handler.verifyScope(['foo']).should.be.an.instanceOf(Promise);
545545
});
@@ -551,7 +551,7 @@ describe('AuthenticateHandler integration', function() {
551551
return true;
552552
}
553553
};
554-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['foo'] });
554+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'foo' });
555555

556556
handler.verifyScope(['foo']).should.be.an.instanceOf(Promise);
557557
});
@@ -576,7 +576,7 @@ describe('AuthenticateHandler integration', function() {
576576
getAccessToken: function() {},
577577
verifyScope: function() {}
578578
};
579-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: false, model: model, scope: ['foo', 'bar'] });
579+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: false, model: model, scope: 'foo bar' });
580580
const response = new Response({ body: {}, headers: {} });
581581

582582
handler.updateResponse(response, { scope: ['foo', 'biz'] });
@@ -602,7 +602,7 @@ describe('AuthenticateHandler integration', function() {
602602
getAccessToken: function() {},
603603
verifyScope: function() {}
604604
};
605-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: false, addAuthorizedScopesHeader: true, model: model, scope: ['foo', 'bar'] });
605+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: false, addAuthorizedScopesHeader: true, model: model, scope: 'foo bar' });
606606
const response = new Response({ body: {}, headers: {} });
607607

608608
handler.updateResponse(response, { scope: ['foo', 'biz'] });

test/integration/handlers/token-handler_test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ describe('TokenHandler integration', function() {
329329
});
330330

331331
it('should not return custom attributes in a bearer token if the allowExtendedTokenAttributes is not set', function() {
332-
const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['foobar'], user: {}, foo: 'bar' };
332+
const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['baz'], user: {}, foo: 'bar' };
333333
const model = {
334334
getClient: function() { return { grants: ['password'] }; },
335335
getUser: function() { return {}; },
@@ -357,14 +357,14 @@ describe('TokenHandler integration', function() {
357357
should.exist(response.body.access_token);
358358
should.exist(response.body.refresh_token);
359359
should.exist(response.body.token_type);
360-
should.exist(response.body.scope);
360+
response.body.scope.should.eql('baz');
361361
should.not.exist(response.body.foo);
362362
})
363363
.catch(should.fail);
364364
});
365365

366366
it('should return custom attributes in a bearer token if the allowExtendedTokenAttributes is set', function() {
367-
const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['foobar'], user: {}, foo: 'bar' };
367+
const token = { accessToken: 'foo', client: {}, refreshToken: 'bar', scope: ['baz'], user: {}, foo: 'bar' };
368368
const model = {
369369
getClient: function() { return { grants: ['password'] }; },
370370
getUser: function() { return {}; },
@@ -392,7 +392,7 @@ describe('TokenHandler integration', function() {
392392
should.exist(response.body.access_token);
393393
should.exist(response.body.refresh_token);
394394
should.exist(response.body.token_type);
395-
should.exist(response.body.scope);
395+
response.body.scope.should.eql('baz');
396396
should.exist(response.body.foo);
397397
})
398398
.catch(should.fail);

test/unit/handlers/authenticate-handler_test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe('AuthenticateHandler', function() {
166166
getAccessToken: function() {},
167167
verifyScope: sinon.stub().returns(true)
168168
};
169-
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: ['bar'] });
169+
const handler = new AuthenticateHandler({ addAcceptedScopesHeader: true, addAuthorizedScopesHeader: true, model: model, scope: 'bar' });
170170

171171
return handler.verifyScope(['foo'])
172172
.then(function() {

test/unit/utils/scope-util_test.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
const { parseScope } = require('../../../lib/utils/scope-util');
2+
const should = require('chai').should();
3+
4+
describe(parseScope.name, () => {
5+
it('should return undefined on nullish values', () => {
6+
const values = [undefined, null];
7+
values.forEach(str => {
8+
const compare = parseScope(str) === undefined;
9+
compare.should.equal(true);
10+
});
11+
});
12+
it('should throw on non-string values', () => {
13+
const invalid = [1, -1, true, false, {}, ['foo'], [], () => {}, Symbol('foo')];
14+
invalid.forEach(str => {
15+
try {
16+
parseScope(str);
17+
should.fail();
18+
} catch (e) {
19+
e.message.should.eql('Invalid parameter: `scope`');
20+
}
21+
});
22+
});
23+
it('should throw on empty strings', () => {
24+
const invalid = ['', ' ', ' ', '\n', '\t', '\r'];
25+
invalid.forEach(str => {
26+
try {
27+
parseScope(str);
28+
should.fail();
29+
} catch (e) {
30+
e.message.should.eql('Invalid parameter: `scope`');
31+
}
32+
});
33+
});
34+
it('should split space-delimited strings into arrays', () => {
35+
const values = [
36+
['foo', ['foo']],
37+
['foo bar', ['foo', 'bar']],
38+
['foo bar', ['foo', 'bar']],
39+
];
40+
values.forEach(([str, compare]) => {
41+
const parsed = parseScope(str);
42+
parsed.should.deep.equal(compare);
43+
});
44+
});
45+
});

0 commit comments

Comments
 (0)