@quarks/quarks-iam
Version:
A modern authorization server built to authenticate your users and protect your APIs
792 lines (567 loc) • 20.9 kB
text/coffeescript
# Test dependencies
cwd = process.cwd()
path = require 'path'
# Test dependencies
cwd = process.cwd()
path = require 'path'
faker = require 'faker'
chai = require 'chai'
sinon = require 'sinon'
sinonChai = require 'sinon-chai'
mockMulti = require '../lib/multi'
expect = chai.expect
# Configure Chai and Sinon
chai.use sinonChai
chai.should()
# Code under test
settings = require path.join(cwd, 'boot/settings')
Modinha = require 'modinha'
AccessToken = require path.join(cwd, 'models/AccessToken')
AccessTokenJWT = require path.join(cwd, 'models/AccessTokenJWT')
{nowSeconds} = require '../../../lib/time-utils'
# Redis lib for spying and stubbing
Redis = require('ioredis')
client = new Redis(12345)
rclient = Redis.prototype
multi = mockMulti(rclient)
AccessToken.__client = client
describe 'AccessToken', ->
{err,validation,instance} = {}
after ->
rclient.multi.restore()
#before ->
# # Mock data
# data = []
# for i in [0..9]
# data.push
# name: "#{faker.Name.firstName()} #{faker.Name.lastName()}"
# email: faker.Internet.email()
# hash: 'private'
# password: 'secret1337'
# users = User.initialize(data, { private: true })
# jsonUsers = users.map (d) ->
# User.serialize(d)
# ids = users.map (d) ->
# d._id
describe 'schema', ->
beforeEach ->
instance = new AccessToken
validation = instance.validate()
it 'should have unique identifier', ->
AccessToken.schema[AccessToken.uniqueId].should.be.a('object')
it 'should generate a default access token', ->
instance.at.length.should.equal 20
it 'should require an access token', ->
AccessToken.schema.at.required.should.equal true
it 'should use the access token as unique identifier', ->
AccessToken.uniqueId.should.equal 'at'
it 'should have token type', ->
AccessToken.schema.tt.type.should.equal 'string'
it 'should enumerate token types', ->
AccessToken.schema.tt.enum.should.contain 'Bearer'
AccessToken.schema.tt.enum.should.contain 'mac'
it 'should default token type to "Bearer"', ->
instance.tt.should.equal 'Bearer'
it 'should have expires in', ->
AccessToken.schema.ei.type.should.equal 'number'
it 'should default expires in to 3600 seconds', ->
instance.ei.should.equal 3600
it 'should have refresh token', ->
AccessToken.schema.rt.type.should.equal 'string'
it 'should index refresh token as unique', ->
AccessToken.schema.rt.unique.should.equal true
it 'should require client id', ->
validation.errors.cid.attribute.should.equal 'required'
it 'should require user id', ->
validation.errors.uid.attribute.should.equal 'required'
it 'should require scope', ->
validation.errors.scope.attribute.should.equal 'required'
# TIMESTAMPS
it 'should have "created" timestamp', ->
AccessToken.schema.created.default.should.equal Modinha.defaults.timestamp
it 'should have "modified" timestamp', ->
AccessToken.schema.modified.default.should.equal Modinha.defaults.timestamp
describe 'exists', ->
{err,exist} = {}
describe 'with pre-existing consent', ->
before (done) ->
sinon.stub(rclient, 'hget')
.callsArgWith(2, null, 'uuid1')
AccessToken.exists 'uuid1', 'uuid2', (error, exists) ->
err = error
exist = exists
done()
it 'should provide true', ->
exist.should.equal true
it 'should not provide an error', ->
expect(err).to.be.null
after ->
rclient.hget.restore()
describe 'without pre-existing consent', ->
before (done) ->
sinon.stub(rclient, 'hget')
.callsArgWith(2, null, null)
AccessToken.exists 'uuid1', 'uuid2', (error, exists) ->
err = error
exist = exists
done()
after ->
rclient.hget.restore()
it 'should provide false', ->
expect(exist).to.be.false
it 'should not provide an error', ->
expect(err).to.be.null
describe 'indexing', ->
describe 'exchange', ->
{res, instance} = {}
describe 'with invalid request', ->
before (done) ->
sinon.stub(AccessToken, 'insert').callsArgWith(1, new Error)
req =
code:
user_id: 'uuid1'
client_id: false # this will cause a validation error
max_age: 600
scope: 'openid profile'
AccessToken.exchange req, (error, response) ->
err = error
res = response
done()
after ->
AccessToken.insert.restore()
it 'should provide an error', ->
expect(err).to.be.an('Error')
it 'should not provide a value', ->
expect(res).to.equal undefined
describe 'with valid request', ->
before (done) ->
instance = new AccessToken
sinon.stub(AccessToken, 'insert').callsArgWith(1, null, instance)
req =
code:
user_id: 'uuid1'
client_id: 'uuid2' # this will cause a validation error
max_age: 600
scope: 'openid profile'
AccessToken.exchange req, (error, result) ->
err = error
instance = result
done()
after ->
AccessToken.insert.restore()
it 'should provide a null error', ->
expect(err).to.be.null
it 'should provide an instance', ->
expect(instance).to.be.instanceof AccessToken
it 'should provide a refresh token', ->
AccessToken.insert.should.have.been.calledWith sinon.match({
rt: sinon.match.string
})
it 'should expire in the default duration', ->
instance.ei.should.equal AccessToken.schema.ei.default
describe 'issue', ->
{res} = {}
describe 'with invalid request', ->
before (done) ->
sinon.stub(AccessToken, 'insert').callsArgWith(1, new Error)
req =
user: {}
client: {}
AccessToken.issue req, (error, response) ->
err = error
res = response
done()
after ->
AccessToken.insert.restore()
it 'should provide an error', ->
expect(err).to.be.an('Error')
it 'should not provide a value', ->
expect(res).to.equal undefined
describe 'with valid request', ->
before (done) ->
instance = new AccessToken
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
scope: 'openid profile'
sinon.stub(AccessToken, 'insert').callsArgWith(1, null, instance)
req =
user: { _id: 'uuid1' }
client: { _id: 'uuid2' }
AccessToken.issue req, (error, response) ->
err = error
res = response
done()
after ->
AccessToken.insert.restore()
it 'should provide a null error', ->
expect(err).to.be.null
it 'should provide an "issue" projection of the token', ->
res.access_token.length.should.be.above 100
options =
key: settings.keys.sig.pub
decoded = AccessTokenJWT.decode(res.access_token, options.key)
decoded.payload.should.have.property('iss', settings.issuer)
decoded.payload.should.have.property('sub', 'uuid1')
decoded.payload.should.have.property 'iat'
decoded.payload.should.have.property 'exp'
decoded.payload.should.have.property 'scope'
it 'should expire in the default duration', ->
res.expires_in.should.equal AccessToken.schema.ei.default
describe 'with max_age parameter', ->
before (done) ->
instance = new AccessToken
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
scope: 'openid profile'
sinon.stub(AccessToken, 'insert').callsArgWith(1, null, instance)
req =
user: { _id: 'uuid1' }
client: { _id: 'uuid2', default_max_age: 7777 }
connectParams: { max_age: '1000' }
AccessToken.issue req, (error, response) ->
err = error
res = response
done()
after ->
AccessToken.insert.restore()
it 'should set expires_in from max_age', ->
AccessToken.insert.should.have.been.calledWith sinon.match({
ei: 1000
})
describe 'with client default_max_age property', ->
before (done) ->
instance = new AccessToken
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
scope: 'openid profile'
sinon.stub(AccessToken, 'insert').callsArgWith(1, null, instance)
req =
user: { _id: 'uuid1' }
client: { _id: 'uuid2', default_max_age: 7777 }
AccessToken.issue req, (error, response) ->
err = error
res = response
done()
after ->
AccessToken.insert.restore()
it 'should set expires_in from default_max_age', ->
AccessToken.insert.should.have.been.calledWith sinon.match({
ei: 7777
})
describe 'refresh', ->
describe 'with unknown refresh token', ->
before (done) ->
sinon.stub(AccessToken, 'getByRt').callsArgWith(1, null, null)
AccessToken.refresh 'r3fr3sh', 'uuid', (error, result) ->
err = error
instance = result
done()
after ->
AccessToken.getByRt.restore()
it 'should provide an error', ->
expect(err).to.be.instanceof AccessToken.InvalidTokenError
it 'should not provide a token', ->
expect(instance).to.be.undefined
describe 'with mismatching client id', ->
before (done) ->
sinon.stub(AccessToken, 'getByRt').callsArgWith(1, null, { cid: 'uuid' })
AccessToken.refresh 'r3fr3sh', 'wrong', (error, result) ->
err = error
instance = result
done()
after ->
AccessToken.getByRt.restore()
it 'should provide an error', ->
expect(err).to.be.instanceof AccessToken.InvalidTokenError
it 'should not provide a token', ->
expect(instance).to.be.undefined
describe 'with valid token', ->
before (done) ->
sinon.stub(multi, 'exec').callsArgWith 0, null, []
sinon.stub(AccessToken, 'delete').callsArgWith(1, null)
sinon.stub(AccessToken, 'getByRt').callsArgWith(1, null, {
at: 't0k3n'
uid: 'uuid1'
cid: 'uuid2'
ei: 600
scope: 'openid profile'
})
sinon.spy(AccessToken, 'insert')
AccessToken.refresh 'r3fr3sh', 'uuid2', (error, result) ->
err = error
instance = result
done()
after ->
multi.exec.restore()
AccessToken.delete.restore()
AccessToken.insert.restore()
AccessToken.getByRt.restore()
it 'should delete the existing token', ->
AccessToken.delete.should.have.been.calledWith 't0k3n'
it 'should provide a null error', ->
expect(err).to.be.null
it 'should provide a new token instance', ->
expect(instance).to.be.instanceof AccessToken
it 'should persist the new token instance with a refresh_token and same client_id', ->
AccessToken.insert.should.have.been.calledWith sinon.match({
cid: 'uuid2'
rt: sinon.match.string
})
it 'should provide a new value for the refresh_token', ->
AccessToken.insert.should.have.not.been.calledWith sinon.match({
rt: 'r3fr3sh'
})
describe 'toJWT', ->
{token,issued,decoded} = {}
describe 'with missing secret', ->
describe 'with invalid secret', ->
describe 'with invalid payload', ->
describe 'with valid payload and secret', ->
before ->
token = new AccessToken
iss: settings.issuer
uid: 'uid'
cid: 'cid'
scope: 'openid'
issued = token.toJWT(settings.keys.sig.prv)
decoded = AccessTokenJWT.decode(issued, settings.keys.sig.pub)
it 'should issue a signed JWT', ->
issued.split('.').length.should.equal 3
it 'should set the jti claim to the access token identifier', ->
decoded.payload.jti.should.equal token.at
it 'should set iss to the issuer', ->
decoded.payload.iss.should.equal settings.issuer
it 'should calculate exp', ->
decoded.payload.exp.should.equal(
decoded.payload.iat + token.ei
)
describe 'revoke', ->
{deleted} = {}
beforeEach (done) ->
token = new AccessToken
uid: 'uuid-1'
cid: 'uuid-2'
sinon.stub(client, 'hget').callsArgWith 2, null, 'fakeId'
sinon.stub(AccessToken, 'delete').callsArgWith 1, null, true
AccessToken.revoke token.uid, token.cid, (error, result) ->
err = error
deleted = result
done()
afterEach ->
client.hget.restore()
AccessToken.delete.restore()
it 'should provide a null error', ->
expect(err).to.be.null
it 'should provide confirmation', ->
deleted.should.be.true
describe 'verify', ->
{claims} = {}
describe 'with undecodable JWT', ->
before (done) ->
token = 'bad.jwt'
options =
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
it 'should provide an error', ->
expect(err).to.be.instanceof Error
it 'should not provide claims', ->
expect(claims).to.be.undefined
describe 'with decodable JWT and mismatching issuer', ->
before (done) ->
token = (new AccessTokenJWT({
at: 'r4nd0m',
iss: 'https://MISMATCHING'
uid: 'uuid1'
cid: 'uuid2'
scope: 'openid'
})).encode(settings.keys.sig.prv)
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
it 'should provide an error', ->
err.error.should.equal 'invalid_token'
it 'should provide an error description', ->
err.error_description.should.equal 'Mismatching issuer'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'with decodable JWT that has expired', ->
before (done) ->
token = (new AccessTokenJWT({
at: 'r4nd0m',
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
exp: nowSeconds(-1)
scope: 'openid'
})).encode(settings.keys.sig.prv)
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
it 'should provide an error', ->
err.error.should.equal 'invalid_token'
it 'should provide an error description', ->
err.error_description.should.equal 'Expired access token'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'with decodable JWT that has insufficient scope', ->
before (done) ->
token = (new AccessTokenJWT({
at: 'r4nd0m',
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
scope: 'openid'
})).encode(settings.keys.sig.prv)
options =
iss: settings.issuer
key: settings.keys.sig.pub
scope: 'other'
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
it 'should provide an error', ->
err.error.should.equal 'insufficient_scope'
it 'should provide an error description', ->
err.error_description.should.equal 'Insufficient scope'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'with random string and unknown token', ->
before (done) ->
sinon.stub(AccessToken, 'get').callsArgWith(1, null, null)
token = 'r4nd0m'
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
after ->
AccessToken.get.restore()
it 'should provide an error', ->
err.error.should.equal 'invalid_request'
it 'should provide an error description', ->
err.error_description.should.equal 'Unknown access token'
it 'should provide a status code', ->
err.statusCode.should.equal 401
describe 'with random string and mismatching issuer', ->
before (done) ->
sinon.stub(AccessToken, 'get').callsArgWith(1, null, {
iss: 'MISMATCH'
})
token = 'r4nd0m'
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
after ->
AccessToken.get.restore()
it 'should provide an error', ->
err.error.should.equal 'invalid_token'
it 'should provide an error description', ->
err.error_description.should.equal 'Mismatching issuer'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'with random string and expired token', ->
before (done) ->
sinon.stub(AccessToken, 'get').callsArgWith(1, null, {
iss: settings.issuer
ei: -10000
created: nowSeconds()
})
token = 'r4nd0m'
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
after ->
AccessToken.get.restore()
it 'should provide an error', ->
err.error.should.equal 'invalid_token'
it 'should provide an error description', ->
err.error_description.should.equal 'Expired access token'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'with random string and insufficient scope', ->
before (done) ->
sinon.stub(AccessToken, 'get').callsArgWith(1, null, {
iss: settings.issuer
ei: 10000
scope: 'openid'
created: nowSeconds()
})
token = 'r4nd0m'
options =
iss: settings.issuer
key: settings.keys.sig.pub
scope: 'other'
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
after ->
AccessToken.get.restore()
it 'should provide an error', ->
err.error.should.equal 'insufficient_scope'
it 'should provide an error description', ->
err.error_description.should.equal 'Insufficient scope'
it 'should provide a status code', ->
err.statusCode.should.equal 403
describe 'valid token', ->
before (done) ->
instance =
at: 'r4nd0m'
iss: settings.issuer
uid: 'uuid1'
cid: 'uuid2'
ei: 10
scope: 'openid'
created: nowSeconds()
sinon.stub(AccessToken, 'get').callsArgWith(1, null, instance)
token = 'r4nd0m'
options =
iss: settings.issuer
key: settings.keys.sig.pub
AccessToken.verify token, options, (error, data) ->
err = error
claims = data
done()
after ->
AccessToken.get.restore()
it 'should provide a null error', ->
expect(err).to.be.null
it 'should provide "jti" claim', ->
claims.jti.should.equal instance.at
it 'should provide "iss" claim', ->
claims.iss.should.equal instance.iss
it 'should provide "sub" claim', ->
claims.sub.should.equal instance.uid
it 'should provide "aud" claim', ->
claims.aud.should.equal instance.cid
it 'should provide "iat" claim', ->
claims.iat.should.equal instance.created
it 'should provide "exp" claim', ->
claims.exp.should.equal instance.created + instance.ei
it 'should provide "scope" claim', ->
claims.scope.should.equal instance.scope