Skip to content

Commit

Permalink
Add support for scoped access tokens
Browse files Browse the repository at this point in the history
Define a new property `AccessToken.scopes` to contain the list of
scopes granted to this access token.

Define a new remote method metadata `accessScopes` to contain a list
of scope name required by this method.

Define a special built-in scope name "DEFAULT" that's used when
a method/token does not provide any scopes. This allows access
tokens to grant access to both the default scope and any additional
custom scopes at the same time.

Modify the authorization algorithm to ensure that at least one
of the scopes required by a remote method is allowed by the scopes
granted to the requesting access token.

The "DEFAULT" scope preserve backwards compatibility because existing
remote methods with no `accessScopes` can be accessed by (existing)
access tokens with no `scopes` defined.

Impact on existing applications:

 - Database schema must be updated after upgrading the loopback version

 - If the application was already using a custom `AccessToken.scopes`
   property with a type different from an array, then the relevant code
   must be updated to work with the new type "array of strings".
  • Loading branch information
bajtos committed Apr 6, 2017
1 parent 9fef528 commit 5c04712
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 3 deletions.
4 changes: 4 additions & 0 deletions common/models/access-token.json
Expand Up @@ -11,6 +11,10 @@
"default": 1209600,
"description": "time to live in seconds (2 weeks by default)"
},
"scopes": {
"type": ["string"],
"description": "Array of scopes granted to this access token."
},
"created": {
"type": "Date",
"defaultFn": "now"
Expand Down
12 changes: 12 additions & 0 deletions common/models/acl.js
Expand Up @@ -256,6 +256,7 @@ module.exports = function(ACL) {
model: req.model,
property: req.property,
accessType: req.accessType,
accessScope: req.accessScope,
permission: permission || ACL.DEFAULT,
registry: this.registry});

Expand Down Expand Up @@ -443,6 +444,7 @@ module.exports = function(ACL) {
var modelDefaultPermission = model && model.settings.defaultPermission;
var property = context.property;
var accessType = context.accessType;
var accessScope = context.accessScope;
var modelName = context.modelName;

var methodNames = context.methodNames;
Expand All @@ -458,10 +460,20 @@ module.exports = function(ACL) {
model: modelName,
property,
accessType,
accessScope,
permission: ACL.DEFAULT,
methodNames,
registry: this.registry});

if (!context.isScopeAllowed()) {
req.permission = ACL.DENY;
debug('--Denied by scope config--');
debug('Scopes allowed:', context.accessToken.scopes || '<default>');
debug('Scope required:', accessScope || '<default>');
context.debug();
return callback(null, req);
}

var effectiveACLs = [];
var staticACLs = self.getStaticACLs(model.modelName, property);

Expand Down
31 changes: 31 additions & 0 deletions lib/access-context.js
Expand Up @@ -32,6 +32,8 @@ var debug = require('debug')('loopback:security:access-context');
* @property {String} property The model property/method/relation name
* @property {String} method The model method to be invoked
* @property {String} accessType The access type: READ, REPLICATE, WRITE, or EXECUTE.
* @property {String[]} accessScopes The access scopes that can authorize
* access to the invoked resource.
* @property {AccessToken} accessToken The access token resolved for the request
* @property {RemotingContext} remotingContext The request's remoting context
* @property {Registry} registry The application or global registry
Expand Down Expand Up @@ -70,6 +72,8 @@ function AccessContext(context) {
}

this.accessType = context.accessType || AccessContext.ALL;
this.accessScopes = context.accessScopes;

assert(loopback.AccessToken,
'AccessToken model must be defined before AccessContext model');
this.accessToken = context.accessToken || loopback.AccessToken.ANONYMOUS;
Expand Down Expand Up @@ -193,6 +197,28 @@ AccessContext.prototype.isAuthenticated = function() {
return !!(this.getUserId() || this.getAppId());
};

/**
* Check if the scope required by the remote method is allowed
* by the scopes granted to the requesting access token.
* @return {boolean}
*/
AccessContext.prototype.isScopeAllowed = function() {
if (!this.accessToken) return false;

// For backwards compatibility, tokens with no scopes are treated
// as if they have "DEFAULT" scope granted
const tokenScopes = this.accessToken.scopes || ['DEFAULT'];

// For backwards compatibility, methods with no scopes defined
// are assigned a single "DEFAULT" scope
const resourceScopes = this.accessScopes || ['DEFAULT'];

// Scope is allowed when at least one of token's scopes
// is found in method's (resource's) scopes.
return Array.isArray(tokenScopes) && Array.isArray(resourceScopes) &&
resourceScopes.some(s => tokenScopes.indexOf(s) !== -1);
};

/*!
* Print debug info for access context.
*/
Expand All @@ -213,10 +239,12 @@ AccessContext.prototype.debug = function() {
debug('property %s', this.property);
debug('method %s', this.method);
debug('accessType %s', this.accessType);
debug('accessScopes %s', this.accessScopes || ['DEFAULT']);
if (this.accessToken) {
debug('accessToken:');
debug(' id %j', this.accessToken.id);
debug(' ttl %j', this.accessToken.ttl);
debug(' scopes', this.accessToken.scopes || ['DEFAULT']);
}
debug('getUserId() %s', this.getUserId());
debug('isAuthenticated() %s', this.isAuthenticated());
Expand Down Expand Up @@ -271,6 +299,7 @@ Principal.prototype.equals = function(p) {
* or an AccessRequest instance/object.
* @param {String} property The property/method/relation name
* @param {String} accessType The access type
* @property {String} accessScope The access scope required by the invoked method.
* @param {String} permission The requested permission
* @param {String[]} methodNames The names of involved methods
* @param {Registry} registry The application or global registry
Expand All @@ -286,6 +315,7 @@ function AccessRequest(model, property, accessType, permission, methodNames, reg
this.model = obj.model || AccessContext.ALL;
this.property = obj.property || AccessContext.ALL;
this.accessType = obj.accessType || AccessContext.ALL;
this.accessScope = obj.accessScope;
this.permission = obj.permission || AccessContext.DEFAULT;
this.methodNames = obj.methodNames || [];
this.registry = obj.registry;
Expand Down Expand Up @@ -370,6 +400,7 @@ AccessRequest.prototype.debug = function() {
debug(' model %s', this.model);
debug(' property %s', this.property);
debug(' accessType %s', this.accessType);
debug(' accessScopes %s', this.accessScopes);
debug(' permission %s', this.permission);
debug(' isWildcard() %s', this.isWildcard());
debug(' isAllowed() %s', this.isAllowed());
Expand Down
1 change: 1 addition & 0 deletions lib/model.js
Expand Up @@ -347,6 +347,7 @@ module.exports = function(registry) {
sharedMethod: sharedMethod,
modelId: modelId,
accessType: this._getAccessTypeForMethod(sharedMethod),
accessScopes: sharedMethod.accessScopes,
remotingContext: ctx,
}, function(err, accessRequest) {
if (err) return callback(err);
Expand Down
1 change: 1 addition & 0 deletions test/acl.test.js
Expand Up @@ -165,6 +165,7 @@ describe('security ACLs', function() {
assert.deepEqual(perm, {model: 'account',
property: 'find',
accessType: 'WRITE',
accessScope: undefined,
permission: 'ALLOW',
methodNames: []});

Expand Down
130 changes: 130 additions & 0 deletions test/authorization-scopes.test.js
@@ -0,0 +1,130 @@
'use strict';

const loopback = require('../');
const supertest = require('supertest');
const strongErrorHandler = require('strong-error-handler');

describe('Authorization scopes', () => {
const CUSTOM_SCOPE = 'read:custom';

let app, request, User, testUser, regularToken, scopedToken;
beforeEach(givenAppAndRequest);
beforeEach(givenRemoteMethodWithCustomScope);
beforeEach(givenUser);
beforeEach(givenDefaultToken);
beforeEach(givenScopedToken);

it('denies regular token to invoke custom-scoped method', () => {
logServerErrorsOtherThan(401);
return request.get('/users/scoped')
.set('Authorization', regularToken.id)
.expect(401);
});

it('allows regular tokens to invoke default-scoped method', () => {
logAllServerErrors();
return request.get('/users/' + testUser.id)
.set('Authorization', regularToken.id)
.expect(200);
});

it('allows scoped token to invoke custom-scoped method', () => {
logAllServerErrors();
return request.get('/users/scoped')
.set('Authorization', scopedToken.id)
.expect(204);
});

it('denies scoped token to invoke default-scoped method', () => {
logServerErrorsOtherThan(401);
return request.get('/users/' + testUser.id)
.set('Authorization', scopedToken.id)
.expect(401);
});

describe('token granted both default and custom scope', () => {
beforeEach('given token with default and custom scope',
() => givenScopedToken(['DEFAULT', CUSTOM_SCOPE]));
beforeEach(logAllServerErrors);

it('allows invocation of default-scoped method', () => {
return request.get('/users/' + testUser.id)
.set('Authorization', scopedToken.id)
.expect(200);
});

it('allows invocation of custom-scoped method', () => {
return request.get('/users/scoped')
.set('Authorization', scopedToken.id)
.expect(204);
});
});

it('allows invocation when at least one method scope is matched', () => {
givenRemoteMethodWithCustomScope(['read', 'write']);
givenScopedToken(['read', 'execute']).then(() => {
return request.get('/users/scoped')
.set('Authorization', scopedToken.id)
.expect(204);
});
});

function givenAppAndRequest() {
app = loopback({localRegistry: true, loadBuiltinModels: true});
app.set('remoting', {rest: {handleErrors: false}});
app.dataSource('db', {connector: 'memory'});
app.enableAuth({dataSource: 'db'});
request = supertest(app);

app.use(loopback.rest());

User = app.models.User;
}

function givenRemoteMethodWithCustomScope() {
const accessScopes = arguments[0] || [CUSTOM_SCOPE];
User.scoped = function(cb) { cb(); };
User.remoteMethod('scoped', {
accessScopes,
http: {verb: 'GET', path: '/scoped'},
});
User.settings.acls.push({
principalType: 'ROLE',
principalId: '$authenticated',
permission: 'ALLOW',
property: 'scoped',
accessType: 'EXECUTE',
});
}

function givenUser() {
return User.create({email: 'test@example.com', password: 'pass'})
.then(u => testUser = u);
}

function givenDefaultToken() {
return testUser.createAccessToken(60)
.then(t => regularToken = t);
}

function givenScopedToken() {
const scopes = arguments[0] || [CUSTOM_SCOPE];
return testUser.accessTokens.create({ttl: 60, scopes})
.then(t => scopedToken = t);
}

function logAllServerErrors() {
logServerErrorsOtherThan(-1);
}

function logServerErrorsOtherThan(statusCode) {
app.use((err, req, res, next) => {
if ((err.statusCode || 500) !== statusCode) {
console.log('Unhandled error for request %s %s: %s',
req.method, req.url, err.stack || err);
}
res.statusCode = err.statusCode || 500;
res.json(err);
});
}
});
6 changes: 3 additions & 3 deletions test/user.test.js
Expand Up @@ -624,18 +624,18 @@ describe('User', function() {
// Override createAccessToken
User.prototype.createAccessToken = function(ttl, options, cb) {
// Reduce the ttl by half for testing purpose
this.accessTokens.create({ttl: ttl / 2, scopes: options.scope}, cb);
this.accessTokens.create({ttl: ttl / 2, scopes: [options.scope]}, cb);
};
User.login(validCredentialsWithTTLAndScope, function(err, accessToken) {
assertGoodToken(accessToken, validCredentialsUser);
assert.equal(accessToken.ttl, 1800);
assert.equal(accessToken.scopes, 'all');
assert.deepEqual(accessToken.scopes, ['all']);

User.findById(accessToken.userId, function(err, user) {
user.createAccessToken(120, {scope: 'default'}, function(err, accessToken) {
assertGoodToken(accessToken, validCredentialsUser);
assert.equal(accessToken.ttl, 60);
assert.equal(accessToken.scopes, 'default');
assert.deepEqual(accessToken.scopes, ['default']);
// Restore create access token
User.prototype.createAccessToken = createToken;

Expand Down

0 comments on commit 5c04712

Please sign in to comment.