Inline the entire resin-cli-auth module

This is part of a general push to demodularize any code that isn't
realistically reusable outside resin-cli, to make the codebase easier to
manage and understand. Once this is done, we'll deprecate the original
module itself.

Change-Type: patch
This commit is contained in:
Tim Perry 2017-11-23 13:49:47 +01:00
parent f106b95be2
commit 001c8f9601
14 changed files with 631 additions and 9 deletions

View File

@ -2,6 +2,8 @@ path = require('path')
gulp = require('gulp')
coffee = require('gulp-coffee')
coffeelint = require('gulp-coffeelint')
inlinesource = require('gulp-inline-source')
mocha = require('gulp-mocha')
shell = require('gulp-shell')
packageJSON = require('./package.json')
@ -10,10 +12,17 @@ OPTIONS =
coffeelint: path.join(__dirname, 'coffeelint.json')
files:
coffee: [ 'lib/**/*.coffee', 'gulpfile.coffee' ]
app: [ 'lib/**/*.coffee', '!lib/**/*.spec.coffee' ]
app: 'lib/**/*.coffee'
tests: 'tests/**/*.spec.coffee'
pages: 'lib/auth/pages/*.ejs'
directories:
build: 'build/'
gulp.task 'pages', ->
gulp.src(OPTIONS.files.pages)
.pipe(inlinesource())
.pipe(gulp.dest('build/auth/pages'))
gulp.task 'coffee', [ 'lint' ], ->
gulp.src(OPTIONS.files.app)
.pipe(coffee(bare: true, header: true))
@ -26,5 +35,16 @@ gulp.task 'lint', ->
}))
.pipe(coffeelint.reporter())
gulp.task 'watch', [ 'coffee' ], ->
gulp.watch([ OPTIONS.files.coffee ], [ 'coffee' ])
gulp.task 'test', ->
gulp.src(OPTIONS.files.tests, read: false)
.pipe(mocha({
reporter: 'min'
}))
gulp.task 'build', [
'coffee',
'pages'
]
gulp.task 'watch', [ 'build' ], ->
gulp.watch([ OPTIONS.files.coffee ], [ 'build' ])

View File

@ -74,7 +74,7 @@ exports.login =
_ = require('lodash')
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
auth = require('resin-cli-auth')
auth = require('../auth')
form = require('resin-cli-form')
patterns = require('../utils/patterns')
messages = require('../utils/messages')

63
lib/auth/index.coffee Normal file
View File

@ -0,0 +1,63 @@
###
Copyright 2016 Resin.io
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
###
###*
# @module auth
###
open = require('open')
resin = require('resin-sdk-preconfigured')
server = require('./server')
utils = require('./utils')
###*
# @summary Login to the Resin CLI using the web dashboard
# @function
# @public
#
# @description
# This function opens the user's default browser and points it
# to the Resin.io dashboard where the session token exchange will
# take place.
#
# Once the the token is retrieved, it's automatically persisted.
#
# @fulfil {String} - session token
# @returns {Promise}
#
# @example
# auth.login().then (sessionToken) ->
# console.log('I\'m logged in!')
# console.log("My session token is: #{sessionToken}")
###
exports.login = ->
options =
port: 8989
path: '/auth'
# Needs to be 127.0.0.1 not localhost, because the ip only is whitelisted
# from mixed content warnings (as the target of a form in the result page)
callbackUrl = "http://127.0.0.1:#{options.port}#{options.path}"
return utils.getDashboardLoginURL(callbackUrl).then (loginUrl) ->
# Leave a bit of time for the
# server to get up and runing
setTimeout ->
open(loginUrl)
, 1000
return server.awaitForToken(options)
.tap(resin.auth.loginWithToken)

21
lib/auth/pages/error.ejs Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Resin CLI - Error</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
</head>
<body>
<div class="center">
<img class="icon" src="./static/images/sad.png" inline>
<h1>Something went wrong</h1>
<p>You couldn't login to the Resin CLI for some reason</p>
<br>
<br>
<a href="https://forums.resin.io/" class="button danger">Get help in our forums</a>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,60 @@
html,
body {
height: 100%;
}
body {
text-align: center;
background-color: #fff;
color: rgb(24, 24, 24);
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
position: relative;
}
.center {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
width: 50%;
height: 50%;
}
.icon {
display: block;
width: 40px;
height: 45px;
margin: 0 auto;
margin-bottom: 15px;
}
h1 {
font-size: 3rem;
margin: 0;
margin-bottom: 12px;
}
p {
color: rgb(99, 99, 99);
font-size: 1.1rem;
margin: 0;
margin-bottom: 15px;
}
a.button {
padding: 15px 25px;
border-radius: 5px;
text-decoration: none;
}
a.button.danger {
background-color: rgb(235, 110, 111);
color: #fff;
}
a.button.normal {
background-color: rgb(252, 191, 44);
color: #fff;
}

View File

@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Resin CLI - Success</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="./static/style.css" inline>
</head>
<body>
<div class="center">
<img class="icon" src="./static/images/happy.png" inline>
<h1>Success!</h1>
<p>You successfully logged in the Resin CLI</p>
<br>
<br>
<a href="<%= dashboardUrl %>" class="button normal">Go to the dashboard</a>
</div>
</body>
</html>

101
lib/auth/server.coffee Normal file
View File

@ -0,0 +1,101 @@
###
Copyright 2016 Resin.io
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
###
express = require('express')
path = require('path')
bodyParser = require('body-parser')
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
utils = require('./utils')
createServer = ({ port, isDev } = {}) ->
app = express()
app.use bodyParser.urlencoded
extended: true
app.set('view engine', 'ejs')
app.set('views', path.join(__dirname, 'pages'))
if isDev
app.use(express.static(path.join(__dirname, 'pages', 'static')))
server = app.listen(port)
return { app, server }
###*
# @summary Await for token
# @function
# @protected
#
# @param {Object} options - options
# @param {String} options.path - callback path
# @param {Number} options.port - http port
#
# @example
# server.awaitForToken
# path: '/auth'
# port: 9001
# .then (token) ->
# console.log(token)
###
exports.awaitForToken = (options) ->
{ app, server } = createServer(port: options.port)
return new Promise (resolve, reject) ->
closeServer = (errorMessage, successPayload) ->
server.close ->
if errorMessage
reject(new Error(errorMessage))
return
resolve(successPayload)
renderAndDone = ({ request, response, viewName, errorMessage, statusCode, token }) ->
return getContext(viewName)
.then (context) ->
response.status(statusCode || 200).render(viewName, context)
request.connection.destroy()
closeServer(errorMessage, token)
app.post options.path, (request, response) ->
token = request.body.token?.trim()
Promise.try ->
if not token
throw new Error('No token')
return utils.isTokenValid(token)
.tap (isValid) ->
if not isValid
throw new Error('Invalid token')
.then ->
renderAndDone({ request, response, viewName: 'success', token })
.catch (error) ->
renderAndDone({
request, response, viewName: 'error',
statusCode: 401, errorMessage: error.message
})
app.use (request, response) ->
response.status(404).send('Not found')
closeServer('Unknown path or verb')
exports.getContext = getContext = (viewName) ->
if viewName is 'success'
return Promise.props
dashboardUrl: resin.settings.get('dashboardUrl')
return Promise.resolve({})

75
lib/auth/utils.coffee Normal file
View File

@ -0,0 +1,75 @@
###
Copyright 2016 Resin.io
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
###
resin = require('resin-sdk-preconfigured')
_ = require('lodash')
url = require('url')
Promise = require('bluebird')
###*
# @summary Get dashboard CLI login URL
# @function
# @protected
#
# @param {String} callbackUrl - callback url
# @fulfil {String} - dashboard login url
# @returns {Promise}
#
# @example
# utils.getDashboardLoginURL('http://127.0.0.1:3000').then (url) ->
# console.log(url)
###
exports.getDashboardLoginURL = (callbackUrl) ->
# Encode percentages signs from the escaped url
# characters to avoid angular getting confused.
callbackUrl = encodeURIComponent(callbackUrl).replace(/%/g, '%25')
resin.settings.get('dashboardUrl').then (dashboardUrl) ->
return url.resolve(dashboardUrl, "/login/cli/#{callbackUrl}")
###*
# @summary Check if a token is valid
# @function
# @protected
#
# @description
# This function checks that the token is not only well-structured
# but that it also authenticates with the server successfully.
#
# @param {String} sessionToken - token
# @fulfil {Boolean} - whether is valid or not
# @returns {Promise}
#
# utils.isTokenValid('...').then (isValid) ->
# if isValid
# console.log('Token is valid!')
###
exports.isTokenValid = (sessionToken) ->
if not sessionToken? or _.isEmpty(sessionToken.trim())
return Promise.resolve(false)
return resin.token.get().then (currentToken) ->
resin.auth.loginWithToken(sessionToken)
.return(sessionToken)
.then(resin.auth.isLoggedIn)
.tap (isLoggedIn) ->
return if isLoggedIn
if currentToken?
return resin.auth.loginWithToken(currentToken)
else
return resin.auth.logout()

View File

@ -19,8 +19,10 @@
"resin": "./bin/resin"
},
"scripts": {
"build": "gulp coffee && tsc && npm run doc",
"ci": "npm run build && catch-uncommitted",
"build": "gulp build && tsc && npm run doc",
"pretest": "npm run build",
"test": "gulp test",
"ci": "npm run test && catch-uncommitted",
"doc": "mkdir -p doc/ && coffee extras/capitanodoc/index.coffee > doc/cli.markdown",
"watch": "gulp watch",
"lint": "gulp lint",
@ -42,7 +44,10 @@
"gulp": "^3.9.0",
"gulp-coffee": "^2.2.0",
"gulp-coffeelint": "^0.6.0",
"gulp-inline-source": "^2.1.0",
"gulp-mocha": "^2.0.0",
"gulp-shell": "^0.5.2",
"mochainon": "^2.0.0",
"require-npm4-to-publish": "^1.0.0",
"typescript": "^2.6.1"
},
@ -52,6 +57,7 @@
"any-promise": "^1.3.0",
"bash": "0.0.1",
"bluebird": "^3.3.3",
"body-parser": "^1.14.1",
"capitano": "^1.7.0",
"chalk": "^1.1.3",
"coffee-script": "^1.12.6",
@ -62,7 +68,9 @@
"dockerode": "^2.5.0",
"dockerode-options": "^0.2.1",
"drivelist": "^5.0.22",
"ejs": "^2.5.7",
"etcher-image-write": "^9.0.3",
"express": "^4.13.3",
"global-tunnel-ng": "github:zvin/global-tunnel#dont-proxy-connections-to-file-sockets",
"hasbin": "^1.2.3",
"inquirer": "^3.1.1",
@ -75,6 +83,7 @@
"mz": "^2.6.0",
"node-cleanup": "^2.1.2",
"nplugm": "^3.0.0",
"open": "0.0.5",
"president": "^2.0.1",
"prettyjson": "^1.1.3",
"progress-stream": "^2.0.0",
@ -82,7 +91,6 @@
"reconfix": "^0.0.3",
"request": "^2.81.0",
"resin-bundle-resolve": "^0.0.2",
"resin-cli-auth": "^1.2.0",
"resin-cli-errors": "^1.2.0",
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.4.0",
@ -105,11 +113,11 @@
"stream-to-promise": "^2.2.0",
"tmp": "0.0.31",
"umount": "^1.1.6",
"underscore.string": "^3.1.1",
"underscore.string": "^3.2.2",
"unzip2": "^0.2.5",
"update-notifier": "^2.2.0"
},
"optionalDependencies": {
"removedrive": "^1.0.0"
}
}
}

View File

@ -0,0 +1,124 @@
m = require('mochainon')
request = require('request')
Promise = require('bluebird')
path = require('path')
fs = require('fs')
ejs = require('ejs')
server = require('../../build/auth/server')
utils = require('../../build/auth/utils')
tokens = require('./tokens.json')
options =
port: 3000
path: '/auth'
getPage = (name) ->
pagePath = path.join(__dirname, '..', '..', 'build', 'auth', 'pages', "#{name}.ejs")
tpl = fs.readFileSync(pagePath, encoding: 'utf8')
compiledTpl = ejs.compile(tpl)
return server.getContext(name)
.then (context) ->
compiledTpl(context)
describe 'Server:', ->
it 'should get 404 if posting to an unknown path', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.be.rejectedWith('Unknown path or verb')
request.post "http://localhost:#{options.port}/foobarbaz",
form:
token: tokens.johndoe.token
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(404)
m.chai.expect(body).to.equal('Not found')
done()
it 'should get 404 if not using the correct verb', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.be.rejectedWith('Unknown path or verb')
request.get "http://localhost:#{options.port}#{options.path}",
form:
token: tokens.johndoe.token
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(404)
m.chai.expect(body).to.equal('Not found')
done()
describe 'given the token authenticates with the server', ->
beforeEach ->
@utilsIsTokenValidStub = m.sinon.stub(utils, 'isTokenValid')
@utilsIsTokenValidStub.returns(Promise.resolve(true))
afterEach ->
@utilsIsTokenValidStub.restore()
it 'should eventually be the token', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.eventually.equal(tokens.johndoe.token)
request.post "http://localhost:#{options.port}#{options.path}",
form:
token: tokens.johndoe.token
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(200)
getPage('success').then (expectedBody) ->
m.chai.expect(body).to.equal(expectedBody)
done()
describe 'given the token does not authenticate with the server', ->
beforeEach ->
@utilsIsTokenValidStub = m.sinon.stub(utils, 'isTokenValid')
@utilsIsTokenValidStub.returns(Promise.resolve(false))
afterEach ->
@utilsIsTokenValidStub.restore()
it 'should be rejected', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.be.rejectedWith('Invalid token')
request.post "http://localhost:#{options.port}#{options.path}",
form:
token: tokens.johndoe.token
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(401)
getPage('error').then (expectedBody) ->
m.chai.expect(body).to.equal(expectedBody)
done()
it 'should be rejected if no token', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.be.rejectedWith('No token')
request.post "http://localhost:#{options.port}#{options.path}",
form:
token: ''
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(401)
getPage('error').then (expectedBody) ->
m.chai.expect(body).to.equal(expectedBody)
done()
it 'should be rejected if token is malformed', (done) ->
promise = server.awaitForToken(options)
m.chai.expect(promise).to.be.rejectedWith('Invalid token')
request.post "http://localhost:#{options.port}#{options.path}",
form:
token: 'asdf'
, (error, response, body) ->
m.chai.expect(error).to.not.exist
m.chai.expect(response.statusCode).to.equal(401)
getPage('error').then (expectedBody) ->
m.chai.expect(body).to.equal(expectedBody)
done()

18
tests/auth/tokens.json Normal file
View File

@ -0,0 +1,18 @@
{
"johndoe": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImpvaG5kb2UxIiwiZW1haWwiOiJqb2huZG9lQGpvaG5kb2UuY29tIiwiZ2l0bGFiX2lkIjoxMzI1LCJzb2NpYWxfc2VydmljZV9hY2NvdW50IjpudWxsLCJoYXNQYXNzd29yZFNldCI6dHJ1ZSwibmVlZHNQYXNzd29yZFJlc2V0IjpmYWxzZSwicHVibGljX2tleSI6ZmFsc2UsImZlYXR1cmVzIjpbXSwiaWQiOjEzNDQsImludGVyY29tVXNlckhhc2giOiJlMDM3NzhkZDI5ZTE1NzQ0NWYyNzJhY2M5MjExNzBjZjI4MTBiNjJmNTAyNjQ1MjY1Y2MzNDlkNmRlZGEzNTI0IiwicGVybWlzc2lvbnMiOltdLCJpYXQiOjE0MjY3ODMzMTJ9.v5bmh9HwyUZu8zhh1rA79mTL-1jzDOO8eUr_lVaBwhg",
"data": {
"email": "johndoe@johndoe.com",
"username": "johndoe1",
"id": 1344
}
},
"janedoe": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6MTUyLCJ1c2VybmFtZSI6ImphbmVkb2UiLCJlbWFpbCI6ImphbmVkb2VAYXNkZi5jb20iLCJzb2NpYWxfc2VydmljZV9hY2NvdW50IjpudWxsLCJoYXNfZGlzYWJsZWRfbmV3c2xldHRlciI6dHJ1ZSwiaGFzUGFzc3dvcmRTZXQiOnRydWUsIm5lZWRzUGFzc3dvcmRSZXNldCI6ZmFsc2UsInB1YmxpY19rZXkiOmZhbHNlLCJmZWF0dXJlcyI6W10sImludGVyY29tVXNlckhhc2giOiIwYjRmOWViNDRiMzcxZjBlMzI4ZWY1ZmUwM2FkN2ViMmY1ZjcyZGQ0MThlZjIzMTQ5ZDUyODcwOTY1NThjZTAzIiwicGVybWlzc2lvbnMiOltdLCJpYXQiOjE0MzUzMjAyNjN9.jVzUFu58vzdJFctR8ulyjGL0Em1kjIZSbSxX2SeU03Y",
"data": {
"email": "janedoe@asdf.com",
"username": "janedoe",
"id": 152
}
}
}

View File

@ -0,0 +1,111 @@
m = require('mochainon')
url = require('url')
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
utils = require('../../build/auth/utils')
tokens = require('./tokens.json')
describe 'Utils:', ->
describe '.getDashboardLoginURL()', ->
it 'should eventually be a valid url', (done) ->
utils.getDashboardLoginURL('https://127.0.0.1:3000/callback').then (loginUrl) ->
m.chai.expect ->
url.parse(loginUrl)
.to.not.throw(Error)
.nodeify(done)
it 'should eventually contain an https protocol', (done) ->
Promise.props
dashboardUrl: resin.settings.get('dashboardUrl')
loginUrl: utils.getDashboardLoginURL('https://127.0.0.1:3000/callback')
.then ({ dashboardUrl, loginUrl }) ->
protocol = url.parse(loginUrl).protocol
m.chai.expect(protocol).to.equal(url.parse(dashboardUrl).protocol)
.nodeify(done)
it 'should correctly escape a callback url without a path', (done) ->
Promise.props
dashboardUrl: resin.settings.get('dashboardUrl')
loginUrl: utils.getDashboardLoginURL('http://127.0.0.1:3000')
.then ({ dashboardUrl, loginUrl }) ->
expectedUrl = "#{dashboardUrl}/login/cli/http%253A%252F%252F127.0.0.1%253A3000"
m.chai.expect(loginUrl).to.equal(expectedUrl)
.nodeify(done)
it 'should correctly escape a callback url with a path', (done) ->
Promise.props
dashboardUrl: resin.settings.get('dashboardUrl')
loginUrl: utils.getDashboardLoginURL('http://127.0.0.1:3000/callback')
.then ({ dashboardUrl, loginUrl }) ->
expectedUrl = "#{dashboardUrl}/login/cli/http%253A%252F%252F127.0.0.1%253A3000%252Fcallback"
m.chai.expect(loginUrl).to.equal(expectedUrl)
.nodeify(done)
describe '.isTokenValid()', ->
it 'should eventually be false if token is undefined', ->
promise = utils.isTokenValid(undefined)
m.chai.expect(promise).to.eventually.be.false
it 'should eventually be false if token is null', ->
promise = utils.isTokenValid(null)
m.chai.expect(promise).to.eventually.be.false
it 'should eventually be false if token is an empty string', ->
promise = utils.isTokenValid('')
m.chai.expect(promise).to.eventually.be.false
it 'should eventually be false if token is a string containing only spaces', ->
promise = utils.isTokenValid(' ')
m.chai.expect(promise).to.eventually.be.false
describe 'given the token does not authenticate with the server', ->
beforeEach ->
@resinAuthIsLoggedInStub = m.sinon.stub(resin.auth, 'isLoggedIn')
@resinAuthIsLoggedInStub.returns(Promise.resolve(false))
afterEach ->
@resinAuthIsLoggedInStub.restore()
it 'should eventually be false', ->
promise = utils.isTokenValid(tokens.johndoe.token)
m.chai.expect(promise).to.eventually.be.false
describe 'given there was a token already', ->
beforeEach (done) ->
resin.auth.loginWithToken(tokens.janedoe.token).nodeify(done)
it 'should preserve the old token', (done) ->
resin.auth.getToken().then (originalToken) ->
m.chai.expect(originalToken).to.equal(tokens.janedoe.token)
return utils.isTokenValid(tokens.johndoe.token)
.then(resin.auth.getToken).then (currentToken) ->
m.chai.expect(currentToken).to.equal(tokens.janedoe.token)
.nodeify(done)
describe 'given there was no token', ->
beforeEach (done) ->
resin.auth.logout().nodeify(done)
it 'should stay without a token', (done) ->
utils.isTokenValid(tokens.johndoe.token).then ->
m.chai.expect(resin.token.get()).to.eventually.not.exist
.nodeify(done)
describe 'given the token does authenticate with the server', ->
beforeEach ->
@resinAuthIsLoggedInStub = m.sinon.stub(resin.auth, 'isLoggedIn')
@resinAuthIsLoggedInStub.returns(Promise.resolve(true))
afterEach ->
@resinAuthIsLoggedInStub.restore()
it 'should eventually be true', ->
promise = utils.isTokenValid(tokens.johndoe.token)
m.chai.expect(promise).to.eventually.be.true