diff --git a/config/circleci.json b/config/circleci.json index b68dcb1..5e76a65 100644 --- a/config/circleci.json +++ b/config/circleci.json @@ -10,5 +10,12 @@ "user": "ubuntu", "password": "" }, + "userAuth": { + "sessionSecret": "v3erY secr€t", + "facebook": { + "clientID": "some_client_id", + "clientSecret": "some_client_secret" + } + }, "version": "v1.0" } diff --git a/config/sample.json b/config/sample.json index 7dbbd7b..fffc94a 100644 --- a/config/sample.json +++ b/config/sample.json @@ -3,6 +3,7 @@ "adminSessionSecret": "default", "env": "dev", "port": 8080, + "publicHost": "http://localhost:8080", "db": { "host": "localhost", "port": 5432, @@ -10,6 +11,13 @@ "user": "postgres", "password": "default" }, + "userAuth": { + "sessionSecret": "default", + "facebook": { + "clientID": "", + "clientSecret": "" + } + }, "sensorthings": { "server": "https://pg-api.sensorup.com", "path": "/st-playground/proxy/v1.0", diff --git a/config/test.json b/config/test.json index 87ae50c..3dce96b 100644 --- a/config/test.json +++ b/config/test.json @@ -3,6 +3,7 @@ "adminSessionSecret": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", "env": "test", "port": 8080, + "publicHost": "http://localhost:8080", "db": { "host": "localhost", "port": 5432, @@ -10,5 +11,12 @@ "user": "postgres", "password": "default" }, + "userAuth": { + "sessionSecret": "v3erY secr€t", + "facebook": { + "clientID": "some_client_id", + "clientSecret": "some_client_secret" + } + }, "version": "v1.0" } diff --git a/package.json b/package.json index 352f697..dc36123 100644 --- a/package.json +++ b/package.json @@ -26,27 +26,36 @@ "babel-cli": "^6.18.0", "babel-preset-env": "^1.1.8", "babel-register": "^6.18.0", + "co": "^4.6.0", + "co-mocha": "^1.1.3", "coveralls": "^2.11.14", "istanbul": "^0.4.5", "mocha": "^3.1.0", + "nock": "^9.0.2", "nodemon": "^1.10.2", "nyc": "^8.3.1", "should": "^11.1.0", - "supertest": "^2.0.0" + "supertest": "^2.0.0", + "supertest-as-promised": "^4.0.2" }, "dependencies": { "body-parser": "^1.15.2", "btoa": "^1.1.2", + "connect-session-sequelize": "^4.1.0", "convict": "^1.5.0", "cors": "^2.8.1", "express": "^4.14.0", "express-http-proxy": "^0.10.0", + "express-session": "^1.14.2", "express-validator": "^2.20.10", "jsonwebtoken": "^7.1.9", "lodash": "^4.15.0", "morgan-body": "^0.9.1", "on-headers": "^1.0.1", "owasp-password-strength-test": "^1.3.0", + "passport": "^0.3.2", + "passport-facebook": "^2.1.1", + "passport-http": "^0.3.0", "pg": "^6.1.0", "sensorthings": "^0.0.10", "sequelize": "^3.24.5" diff --git a/src/config.js b/src/config.js index 088c1f5..9c912bb 100644 --- a/src/config.js +++ b/src/config.js @@ -26,6 +26,15 @@ convict.addFormat({ coerce: (val) => (val === null ? null : parseInt(val)) }); +convict.addFormat({ + name: 'hex', + validate: function(val) { + if (/[^a-fA-F0-9]/.test(val)) { + throw new Error('must be a hex key'); + } + } +}); + const conf = convict({ adminPass: { doc: 'The password for the admin user. Follow OWASP guidelines for passwords', @@ -72,6 +81,9 @@ const conf = convict({ default: '', } }, + publicHost: { + doc: 'Public host for this server, especially for auth callback' + }, sensorthings: { server: { doc: 'SensorThings remote API server', @@ -93,6 +105,39 @@ const conf = convict({ } } }, + behindProxy: { + doc: `Set this to true if the server runs behind a reverse proxy. This is + especially important if the proxy implements HTTPS with + userAuth.cookieSecure. Also with this Express will trust the + X-Forwarded-For header. Set to 1 or auto if you're behind a proxy.`, + default: false, + // Format is "*" because otherwise convict infers it's a boolean from the + // default value and will refuse 1 or "auto". + format: '*', + }, + userAuth: { + cookieSecure: { + doc: `This configures whether the cookie should be set and sent for + HTTPS only. This is important to set to 'true' if the application + runs on HTTPS.`, + default: false, + }, + sessionSecret: { + doc: 'This secret is used to sign session cookie', + default: defaultValue, + format: avoidDefault, + }, + facebook: { + clientId: { + doc: 'Facebook clientId', + format: 'nat' + }, + clientSecret: { + doc: 'Facebook clientSecret', + format: 'hex' + }, + }, + }, version: { doc: 'API version. We follow SensorThing\'s versioning format as described at http://docs.opengeospatial.org/is/15-078r6/15-078r6.html#34', format: value => { diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index bed4683..dfda45d 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -4,6 +4,7 @@ import jwt from 'jsonwebtoken'; import config from '../config'; +import db from '../models/db'; import { ApiError, @@ -16,13 +17,25 @@ function unauthorized(res) { } export default (scopes) => { + // For now we only allow 'admin' scope. + const validScopes = ['admin', 'client', 'user'].filter( + scope => scopes.includes(scope) + ); + + if (!validScopes.length) { + throw new Error(`No valid scope found in "${scopes}"`); + } + return (req, res, next) => { - const authHeader = req.headers['authorization']; - if (!authHeader) { + const authHeader = req.headers.authorization; + // Accepting a query parameter as well allows GET requests. + const authQuery = req.query.authorizationToken; + + if (!authHeader && !authQuery) { return unauthorized(res); } - const token = authHeader.split('Bearer ')[1]; + const token = authHeader ? authHeader.split('Bearer ')[1] : authQuery; if (!token) { return unauthorized(res); } @@ -31,36 +44,43 @@ export default (scopes) => { // need to get the owner of the token so we can get the appropriate secret. const decoded = jwt.decode(token); - // For now we only allow authenticated requests from the admin user. - // When this changes we will have a different secret per sensor and per - // user. - if (!decoded || !decoded.id || decoded.id !== 'admin') { + if (!decoded || !decoded.id || !decoded.scope) { return unauthorized(res); - }; + } - const secret = config.get('adminSessionSecret'); + if (!validScopes.includes(decoded.scope)) { + console.log('Error while authenticating, invalid scope', decoded); + return unauthorized(res); + } - // Verify JWT signature. - jwt.verify(token, secret, (error, decoded) => { - if (error) { - return unauthorized(res); - } + let secretPromise; + switch(decoded.scope) { + case 'client': + secretPromise = db().then(({ Clients }) => + Clients.findById(decoded.id, { attributes: ['secret'] }) + ).then(client => client.secret); + break; + case 'user': + case 'admin': + secretPromise = Promise.resolve(config.get('adminSessionSecret')); + break; + default: + // should not happen because we check this earlier + next(new Error(`Unknown scope ${decoded.scope}`)); + } - // XXX Get allowed scopes from sensor/user. + // Verify JWT signature. + secretPromise.then(secret => { + jwt.verify(token, secret, (error) => { + if (error) { + console.log('Error while verifying the token', error); + return unauthorized(res); + } - // For now we only allow 'admin' scope. - const validScopes = ['admin'].filter(scopeIndex => { - return scopes.indexOf(scopeIndex) != -1; + req[decoded.scope] = decoded.id; + req.authScope = decoded.scope; + return next(); }); - - if (!validScopes.length) { - return unauthorized(res); - } - - // If everything is good, save the decoded payload for use in other - // routes. - req.decoded = decoded; - next(); - }); + }).catch(err => next(err || new Error('Unexpected error'))); }; }; diff --git a/src/models/db.js b/src/models/db.js index cb9ca69..1a32826 100644 --- a/src/models/db.js +++ b/src/models/db.js @@ -27,7 +27,16 @@ let deferreds = []; let state = IDLE; let db = null; -module.exports = () => { +const { name, user, password, host, port } = config.get('db'); + +const sequelize = new Sequelize(name, user, password, { + host, + port, + dialect: 'postgres', + logging: false +}); + +export default function() { if (state === READY) { return Promise.resolve(db); } @@ -41,24 +50,13 @@ module.exports = () => { state = INITIALIZING; - const dbConfig = config.get('db'); - const { name, user, password, host, port } = dbConfig; - - const sequelize = new Sequelize(name, user, password, { - host, - port, - dialect: 'postgres', - logging: false - }); - db = {}; fs.readdirSync(__dirname) .filter(file => { return ((file.indexOf('.js') !== 0) && - (file !== 'db.js') && - (file !== 'users.js') && - (file.indexOf('.swp') < 0)); + !file.startsWith('.') && + (file !== 'db.js')); }) .forEach(file => { const model = sequelize.import(path.join(__dirname, file)); @@ -74,7 +72,7 @@ module.exports = () => { db.sequelize = sequelize; db.Sequelize = Sequelize; - return db.sequelize.sync().then(() => { + return sequelize.sync().then(() => { while (deferreds.length) { deferreds.pop().resolve(db); } @@ -86,4 +84,6 @@ module.exports = () => { deferreds.pop().reject(e); } }); -}; +} + +export { sequelize, Sequelize }; diff --git a/src/models/users.js b/src/models/users.js index fa85b8c..99bbbad 100644 --- a/src/models/users.js +++ b/src/models/users.js @@ -1,6 +1,3 @@ -import btoa from 'btoa'; -import jwt from 'jsonwebtoken'; - import config from '../config'; import { UNAUTHORIZED, @@ -9,22 +6,70 @@ import { // Supported authentication methods. const authMethods = { - BASIC: 'basic' + BASIC: ({ username, password }) => { + // For now we only support admin authentication. + if (username !== 'admin' || password !== config.get('adminPass')) { + return Promise.reject(new Error(UNAUTHORIZED)); + } + + // Currently Basic authentication is for admins only so let's hardcode this. + return Promise.resolve({ + id: 'admin', + scope: 'admin' + }); + }, + + AUTH_PROVIDER: (data) => { + // We use this authentication method only for users. + return Promise.resolve({ + id: data, + scope: 'user' + }); + }, +}; + +module.exports = (sequelize, DataTypes) => { + const User = sequelize.define('Users', { + opaqueId: { type: DataTypes.STRING(256) }, + provider: { type: DataTypes.STRING(32) }, + }, { + indexes: [{ + unique: true, + fields: ['opaqueId', 'provider', 'clientKey'] + // note: clientKey is a foreign key created at the association steps below + }] + }); + + User.authenticate = (method, data) => { + if (!authMethods[method]) { + return Promise.reject(new Error(UNSUPPORTED_AUTH_METHOD)); + } + + return authMethods[method](data) + .then(userData => { + if (userData.scope !== 'user') { + return userData; + } + + return User.findOrCreate({ + attributes: [], + where: userData.id, // this contains all user attributes + }).then(() => userData); + }); + }; + + User.associate = (db) => { + db.Users.belongsTo( + db.Clients, + { + foreignKey: { name: 'clientKey', allowNull: false }, + onDelete: 'CASCADE', + } + ); + db.Clients.hasMany(db.Users, { foreignKey: 'clientKey' }); + }; + + Object.keys(authMethods).forEach(key => { User[key] = key; }); + + return User; }; -Object.keys(authMethods).forEach(key => exports[key] = key); - -exports.authenticate = (method, data) => { - if (!authMethods[method]) { - return Promise.reject(new Error(UNSUPPORTED_AUTH_METHOD)); - } - - // For now we only support admin authentication. - if (btoa('admin:' + config.get('adminPass')) !== data) { - return Promise.reject(new Error(UNAUTHORIZED)); - } - - return Promise.resolve(jwt.sign({ - id: 'admin', - scope: 'admin' - }, config.get('adminSessionSecret'))); -} diff --git a/src/routes/auth/basic.js b/src/routes/auth/basic.js new file mode 100644 index 0000000..dfdb74f --- /dev/null +++ b/src/routes/auth/basic.js @@ -0,0 +1,53 @@ +import express from 'express'; + +import passport from 'passport'; +import { BasicStrategy } from 'passport-http'; + +import db from '../../models/db'; +import finalizeAuth from './finalize_auth'; +import { ApiError, UNAUTHORIZED, ERRNO_UNAUTHORIZED } from '../../errors'; + +passport.use(new BasicStrategy( + (username, password, done) => { + db().then(models => { + const { BASIC, authenticate } = models.Users; + return authenticate(BASIC, { username, password }); + }).then( + userInfo => done(null, userInfo), + err => { + if (err.message === UNAUTHORIZED) { + // passing back `false` to passport means "no user found" + done(null, false); + return; + } + done(err); + } + ); + } +)); + +const router = express.Router(); + +router.post('/', + (req, res, next) => { + passport.authenticate( + 'basic', + (err, user, _info) => { + // TODO we can probably remove this callback with issue #44 + if (err) { + return next(err); + } + + if (!user) { + return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); + } + + req.user = user; + return next(); + } + )(req, res, next); + }, + finalizeAuth +); + +export default router; diff --git a/src/routes/auth/facebook.js b/src/routes/auth/facebook.js new file mode 100644 index 0000000..e13f43c --- /dev/null +++ b/src/routes/auth/facebook.js @@ -0,0 +1,128 @@ +import express from 'express'; +import passport from 'passport'; +import { Strategy } from 'passport-facebook'; + +import config from '../../config'; +import db from '../../models/db'; +import auth from '../../middlewares/auth'; +import finalizeAuth from './finalize_auth'; +import { + ApiError, + BAD_REQUEST, ERRNO_BAD_REQUEST, + FORBIDDEN, ERRNO_FORBIDDEN, + UNAUTHORIZED, ERRNO_UNAUTHORIZED, +} from '../../errors'; + +const router = express.Router(); + +const callbackURL = + `${config.get('publicHost')}/${config.get('version')}/auth/facebook/callback`; + +passport.use(new Strategy( + { + clientID: config.get('userAuth.facebook.clientID'), + clientSecret: config.get('userAuth.facebook.clientSecret'), + callbackURL, + passReqToCallback: true, + enableProof: true, + state: true, + }, + function(req, accessToken, refreshToken, profile, cb) { + db().then(models => { + const { AUTH_PROVIDER, authenticate } = models.Users; + return authenticate( + AUTH_PROVIDER, + { + opaqueId: profile.id, + provider: 'facebook', + clientKey: req.session.clientKey, + } + ); + }).then( + userInfo => cb(null, userInfo), + err => cb(err) + ); + } +)); + +function checkClientExists(req, res, next) { + db() + .then(({ Clients }) => + Clients.findById(req.client, { attributes: { exclude: ['secret'] }}) + ).then(client => { + if (client) { + req.client = client; + return next(); + } + + return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); + }); +} + +function checkHasValidSession(req, res, next) { + if (!req.session.valid) { + return ApiError(res, 403, ERRNO_FORBIDDEN, FORBIDDEN); + } + + return next(); +} + +router.get('/', + auth(['client']), + checkClientExists, + (req, res, next) => { + const client = req.client; + const { redirectUrl, failureUrl } = req.query; + + const authRedirectUrls = client.authRedirectUrls || []; + const authFailureRedirectUrls = client.authFailureRedirectUrls || []; + + if (!authRedirectUrls.includes(redirectUrl) || + (failureUrl && !authFailureRedirectUrls.includes(failureUrl))) { + return ApiError( + res, 400, ERRNO_BAD_REQUEST, BAD_REQUEST, + 'The redirect and failure URLs must be declared in your account' + ); + } + + req.session.valid = true; + req.session.redirectUrl = redirectUrl; + req.session.failureUrl = failureUrl; + req.session.clientKey = client.key; + + return passport.authenticate( + 'facebook', { session: false } + )(req, res, next); + } +); + +router.get( + '/callback', + checkHasValidSession, + (req, res, next) => { + passport.authenticate( + 'facebook', + // Using the callback is the only way to customize how to respond to a + // failure. Otherwise it's always a 401 without a body. + (err, user, _info) => { + if (err) { + return next(err); + } + + if (!user) { + if (req.session.failureUrl) { + return res.redirect(req.session.failureUrl); + } + + return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); + } + + req.user = user; + return next(); + } + )(req, res, next); + }, + finalizeAuth +); + +export default router; diff --git a/src/routes/auth/finalize_auth.js b/src/routes/auth/finalize_auth.js new file mode 100644 index 0000000..e310a5f --- /dev/null +++ b/src/routes/auth/finalize_auth.js @@ -0,0 +1,23 @@ +import jwt from 'jsonwebtoken'; +import url from 'url'; + +import config from '../../config'; + +export default function finalizeAuth(req, res) { + const userData = req.user; + delete req.user; + req[userData.scope] = userData.id; + req.authScope = userData.scope; + + const token = jwt.sign(userData, config.get('adminSessionSecret')); + + if (req.session && req.session.redirectUrl) { + const redirectUrl = url.parse(req.session.redirectUrl, true); + redirectUrl.query.token = token; + req.session.destroy(); + res.redirect(url.format(redirectUrl)); + return; + } + + res.status(201).json({ token }); +} diff --git a/src/routes/auth/index.js b/src/routes/auth/index.js new file mode 100644 index 0000000..496e86e --- /dev/null +++ b/src/routes/auth/index.js @@ -0,0 +1,42 @@ +import express from 'express'; +import session from 'express-session'; +import SequelizeStoreFactory from 'connect-session-sequelize'; + +import basic from './basic'; +import facebook from './facebook'; + +import config from '../../config'; +import { sequelize } from '../../models/db'; + +const router = express.Router(); + +// We don't need the session handling for Basic authentication, that's why we +// configure it here before inserting the session middleware. +router.use('/basic', basic); + +// initalize sequelize with session store +const SequelizeStore = SequelizeStoreFactory(session.Store); +const isProduction = process.env.NODE_ENV === 'production'; +router.use(session({ + secret: config.get('userAuth.sessionSecret'), + store: new SequelizeStore({ + db: sequelize + }), + cookie: { + path: `/${config.get('version')}/auth`, + secure: config.get('userAuth.cookieSecure'), + }, + name: 'connect.sid.auth', + resave: false, + saveUninitialized: false, +})); + +if (config.get('userAuth.facebook.clientID')) { + router.use('/facebook', facebook); +} else { + console.log( + 'No Facebook configuration, so not loading Facebook login endpoint' + ); +} + +export default router; diff --git a/src/routes/users.js b/src/routes/users.js deleted file mode 100644 index 62321bc..0000000 --- a/src/routes/users.js +++ /dev/null @@ -1,32 +0,0 @@ -import express from 'express'; - -import config from '../config'; -import { - ApiError, - ERRNO_UNAUTHORIZED, - UNAUTHORIZED -} from '../errors'; -import { - authenticate, - BASIC -} from '../models/users'; - -let router = express.Router(); - -router.post('/auth', (req, res) => { - if (!req.headers || !req.headers.authorization) { - return ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); - } - - // For now we only accept basic authentication and only for the - // admin user. - const pass = req.headers.authorization.substr('Basic '.length); - - authenticate(BASIC, pass).then(token => { - res.status(201).json({ token }); - }).catch(error => { - ApiError(res, 401, ERRNO_UNAUTHORIZED, UNAUTHORIZED); - }); -}); - -export default router; diff --git a/src/server.js b/src/server.js index 71c5d34..8621267 100644 --- a/src/server.js +++ b/src/server.js @@ -11,12 +11,14 @@ import config from './config'; import clients from './routes/clients'; import dockerflow from './routes/dockerflow'; -import users from './routes/users'; +import authRouter from './routes/auth'; import sensorthings from './routes/sensorthings'; let app = express(); +app.set('trust proxy', config.get('behindProxy')); + app.use(bodyParser.urlencoded({ extended: false })); app.use(expressValidator({ customValidators: { @@ -29,7 +31,7 @@ app.use(expressValidator({ })); app.use(bodyParser.json()); -if (config.get('env') !== 'test') { +if (config.get('env') !== 'test' || process.env.FORCE_OUTPUT) { logger(app); } @@ -42,7 +44,7 @@ app.use('/', sensorthings); const endpointPrefix = '/' + config.get('version'); app.use(endpointPrefix + '/clients', auth(['admin']), clients); -app.use(endpointPrefix + '/users', users); +app.use(endpointPrefix + '/auth', authRouter); const port = config.get('port'); app.listen(port, () => console.log(`Running on localhost:${port}`)); diff --git a/test/common.js b/test/common.js new file mode 100644 index 0000000..b712e52 --- /dev/null +++ b/test/common.js @@ -0,0 +1,36 @@ +import co from 'co'; +import jwt from 'jsonwebtoken'; + +import config from '../src/config'; + +const endpointPrefix = `/${config.get('version')}`; + +export function loginAsAdmin(server) { + return co(function*() { + const res = yield server.post(`${endpointPrefix}/auth/basic`) + .auth('admin', config.get('adminPass')) + .expect(201) + .expect(res => res.body.token); + + return res.body.token; + }); +} + +export function createClient(server, adminToken, client) { + return co(function*() { + const res = yield server.post(`${endpointPrefix}/clients`) + .set('Authorization', `Bearer ${adminToken}`) + .send(client) + .expect(201); + + return res.body; + }); +} + +export function loginAsClient(server, client) { + // TODO we should login as the client instead of generating a JWT here + return Promise.resolve(jwt.sign({ + id: client.key, + scope: 'client' + }, client.secret)); +} diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..1ea4037 --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,2 @@ +--require co-mocha +--timeout 10000 diff --git a/test/test_auth_api.js b/test/test_auth_api.js new file mode 100644 index 0000000..aa4b44c --- /dev/null +++ b/test/test_auth_api.js @@ -0,0 +1,229 @@ +import btoa from 'btoa'; +import jwt from 'jsonwebtoken'; +import nock from 'nock'; +import should from 'should'; +import supertest from 'supertest-as-promised'; +import url from 'url'; + +import app from '../src/server'; +import db from '../src/models/db'; +import config from '../src/config'; +import { + errnos, + ERRNO_UNAUTHORIZED, + errors, + UNAUTHORIZED +} from '../src/errors'; + +import { + loginAsAdmin, + loginAsClient, + createClient, +} from './common'; + +const endpointPrefix = '/' + config.get('version'); +const server = supertest(app); + +describe('Authentication API', () => { + // TODO Use Template Strings, promises and generators. (issue #59) + describe('POST ' + endpointPrefix + '/auth/basic', () => { + it('should respond 401 Unauthorized if there is no auth header', done => { + server.post(endpointPrefix + '/auth/basic') + .expect(401) + .end((err, res) => { + res.status.should.be.equal(401); + res.body.code.should.be.equal(401); + res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); + res.body.error.should.be.equal(errors[UNAUTHORIZED]); + done(); + }); + }); + + it('should respond 401 Unauthorized if auth header is invalid', done => { + server.post(endpointPrefix + '/auth/basic') + .set('Authorization', 'Invalid') + .expect(401) + .end((err, res) => { + res.status.should.be.equal(401); + res.body.code.should.be.equal(401); + res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); + res.body.error.should.be.equal(errors[UNAUTHORIZED]); + done(); + }); + }); + + it('should respond 401 Unauthorized if admin pass is incorrect', done => { + server.post(endpointPrefix + '/auth/basic') + .set('Authorization', 'Basic invalidpassword') + .expect(401) + .end((err, res) => { + res.status.should.be.equal(401); + res.body.code.should.be.equal(401); + res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); + res.body.error.should.be.equal(errors[UNAUTHORIZED]); + done(); + }); + }); + + it('should respond 201 Created if admin pass is correct', done => { + const pass = btoa('admin:' + config.get('adminPass')); + server.post(endpointPrefix + '/auth/basic') + .set('Authorization', 'Basic ' + pass) + .expect(200) + .end((err, res) => { + res.status.should.be.equal(201); + should.exist(res.body.token); + done(); + }); + }); + }); + + describe(`GET ${endpointPrefix}/auth/facebook`, function() { + const endpoint = `${endpointPrefix}/auth/facebook`; + const redirectUrls = [ + 'http://redirect.me/1', + 'http://redirect.me/2' + ]; + const failureRedirectUrls = [ + 'http://failure.redirect.me/1', + 'http://failure.redirect.me/2' + ]; + + beforeEach(function*() { + const { Clients } = yield db(); + yield Clients.destroy({ where: {}}); + }); + + it('should respond 401 unauthorized if there is no client token', function*() { + yield server.get(endpoint) + .expect(401) + .expect({ + code: 401, + errno: errnos[ERRNO_UNAUTHORIZED], + error: errors[UNAUTHORIZED] + }); + }); + + it('should respond 401 unauthorized with a bad token', function*() { + yield server.get(endpoint) + .query({ authorizationToken: 'blablabla' }) + .expect(401) + .expect({ + code: 401, + errno: errnos[ERRNO_UNAUTHORIZED], + error: errors[UNAUTHORIZED] + }); + + }); + + it('should respond 400 if the client has no redirect url', function*() { + const adminToken = yield loginAsAdmin(server); + const client = yield createClient(server, adminToken, { name: 'test' }); + const clientToken = yield loginAsClient(server, client); + + yield server.get(endpoint) + .query({ authorizationToken: clientToken }) + .query({ redirectUrl: redirectUrls[0] }) + .expect(400); + }); + + it('should respond 400 if the redirect urls mismatch', function*() { + const adminToken = yield loginAsAdmin(server); + const client = yield createClient( + server, adminToken, + { + name: 'test', + authRedirectUrls: [ redirectUrls[0] ], + authFailureRedirectUrls: [ failureRedirectUrls[0] ], + } + ); + + const clientToken = yield loginAsClient(server, client); + + yield server.get(endpoint) + .query({ authorizationToken: clientToken }) + .query({ redirectUrl: redirectUrls[1] }) + .expect(400); + + yield server.get(endpoint) + .query({ authorizationToken: clientToken }) + .expect(400); + + yield server.get(endpoint) + .query({ authorizationToken: clientToken }) + .query({ redirectUrl: redirectUrls[0] }) + .expect(302); + }); + + it('callback should 403 Forbidden if there is no session', function*() { + yield server.get(`${endpoint}/callback`) + .query({ code: 'some_code' }) + .expect(403); + }); + + it('facebook login flow with a proper token', function*() { + const adminToken = yield loginAsAdmin(server); + const client = yield createClient( + server, adminToken, + { + name: 'test', + authRedirectUrls: redirectUrls, + authFailureRedirectUrls: failureRedirectUrls, + } + ); + + const clientToken = yield loginAsClient(server, client); + + // Supertest's agent keeps the cookies + const agent = supertest.agent(app); + let res = yield agent.get(endpoint) + .query({ authorizationToken: clientToken }) + .query({ redirectUrl: redirectUrls[1] }) + .query({ failureUrl: failureRedirectUrls[1] }) + .expect(302) + .expect('location', /facebook\.com/) + .expect('set-cookie', /^connect\.sid\.auth=/); + + // This is Facebook's anti-CSRF protection + const state = url.parse(res.headers.location, true).query.state; + + // Let's mock the Facebook Graph server + const facebook = nock('https://graph.facebook.com') + .post('/oauth/access_token') + .query(true) + .reply( + 200, { access_token: 'access_token', refresh_token: 'refresh_token' } + ) + + .get('/v2.5/me') + .query(true) + .reply(200, { id: 'facebook_id' }); + + const expectedId = { + opaqueId: 'facebook_id', + provider: 'facebook', + clientKey: client.key, + }; + const expectedToken = jwt.sign( + { id: expectedId, scope: 'user' }, + config.get('adminSessionSecret') + ); + + yield agent.get(`${endpoint}/callback`) + .query({ code: 'facebook_return_code' }) + .query({ state }) + .expect(302) + .expect( + 'location', `${redirectUrls[1]}?token=${expectedToken}` + ); + + facebook.done(); + nock.cleanAll(); + nock.restore(); + + const { Users } = yield db(); + const user = yield Users.findOne({ where: expectedId }); + should.exist(user); + }); + }); +}); diff --git a/test/test_clients_api.js b/test/test_clients_api.js index 7ed4ee2..c9d2092 100644 --- a/test/test_clients_api.js +++ b/test/test_clients_api.js @@ -23,8 +23,9 @@ let token; describe('Clients API', () => { before((done) => { + // XXX use common's loginAsAdmin function (issue #60) const pass = btoa('admin:' + config.get('adminPass')); - server.post(endpointPrefix + '/users/auth') + server.post(endpointPrefix + '/auth/basic') .set('Authorization', 'Basic ' + pass) .end((err, res) => { should.exist(res.body.token); diff --git a/test/test_users_api.js b/test/test_users_api.js deleted file mode 100644 index 884d125..0000000 --- a/test/test_users_api.js +++ /dev/null @@ -1,76 +0,0 @@ -import btoa from 'btoa'; -import should from 'should'; -import supertest from 'supertest'; - -import app from '../src/server'; -import users from '../src/models/users'; -import config from '../src/config'; -import { - errnos, - ERRNO_UNAUTHORIZED, - errors, - UNAUTHORIZED -} from '../src/errors'; - -const endpointPrefix = '/' + config.get('version'); -const server = supertest.agent(app); - -describe('Users API', () => { - describe('POST ' + endpointPrefix + '/users/auth', () => { - it('should response 401 Unauthorized if there is no auth header', done => { - server.post(endpointPrefix + '/users/auth') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); - }); - }); - - describe('POST ' + endpointPrefix + '/users/auth', () => { - it('should response 401 Unauthorized if auth header is invalid', done => { - server.post(endpointPrefix + '/users/auth') - .set('Authorization', 'Invalid') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); - }); - }); - - describe('POST ' + endpointPrefix + '/users/auth', () => { - it('should response 401 Unauthorized if admin pass is incorrect', done => { - server.post(endpointPrefix + '/users/auth') - .set('Authorization', 'Basic invalidpassword') - .expect(401) - .end((err, res) => { - res.status.should.be.equal(401); - res.body.code.should.be.equal(401); - res.body.errno.should.be.equal(errnos[ERRNO_UNAUTHORIZED]); - res.body.error.should.be.equal(errors[UNAUTHORIZED]); - done(); - }); - }); - }); - - describe('POST ' + endpointPrefix + '/users/auth', () => { - it('should response 201 Created if admin pass is correct', done => { - const pass = btoa('admin:' + config.get('adminPass')); - server.post(endpointPrefix + '/users/auth') - .set('Authorization', 'Basic ' + pass) - .expect(201) - .end((err, res) => { - res.status.should.be.equal(201); - should.exist(res.body.token); - done(); - }); - }); - }); -});