diff --git a/src/base.js b/src/base.js new file mode 100644 index 00000000..bafe1365 --- /dev/null +++ b/src/base.js @@ -0,0 +1,87 @@ +import Debug from 'debug'; +import jwt from 'jsonwebtoken'; + +const debug = Debug('feathers-authentication'); + +export default class Authentication { + constructor(app, options) { + this.options = options; + this.app = app; + this._middleware = []; + } + + use(... middleware) { + const mapped = middleware.map(current => + current.call(this.app, this.options) + ); + + this._middleware = this._middleware.concat(mapped); + + return this; + } + + authenticate(data) { + let promise = Promise.resolve(data); + + debug('Authenticating', data); + + this._middleware.forEach(middleware => + promise = promise.then(data => + middleware.call(this.app, data, this.options) + ) + ); + + return promise; + } + + verifyJWT(data, params) { + const settings = Object.assign({}, this.options.jwt, params); + const token = typeof data === 'string' ? data : data.token; + const { secret } = this.options; + + debug('Verifying token', token); + + return new Promise((resolve, reject) => { + jwt.verify(token, secret, settings, (error, payload) => { + if(error) { + debug('Error verifying token', error); + return reject(error); + } + + debug('Verified token with payload', payload); + resolve({ token, payload }); + }); + }); + } + + createJWT(data, params) { + const settings = Object.assign({}, this.options.jwt, params); + const { secret } = this.options; + + if(data.iss) { + delete settings.issuer; + } + + if(data.sub) { + delete settings.subject; + } + + if(data.exp) { + delete settings.expiresIn; + } + + return new Promise((resolve, reject) => { + debug('Creating JWT using options', settings); + + jwt.sign(data, secret, settings, (error, token) => { + if (error) { + debug('Error signing JWT', error); + return reject(error); + } + + debug('New JWT issued with payload', data); + return resolve({ token, payload: data }); + }); + }); + } +} diff --git a/src/index.js b/src/index.js index 164b3865..2fc038b1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,5 @@ import Debug from 'debug'; import passport from 'passport'; -import merge from 'lodash.merge'; // Exposed modules import hooks from './hooks'; @@ -8,69 +7,26 @@ import token from './services/token'; import local from './services/local'; import oauth2 from './services/oauth2'; import * as mw from './middleware'; +import getOptions from './options'; +import Authentication from './base'; const debug = Debug('feathers-authentication:main'); -// Options that apply to any provider -const defaults = { - header: 'Authorization', - setupMiddleware: true, // optional - to setup middleware yourself set to false. - cookie: { // Used for redirects, server side rendering and OAuth - enabled: false, // Set to true to enable setting cookies - name: 'feathers-jwt', - httpOnly: false, - secure: true - }, - token: { - name: 'token', // optional - service: '/auth/token', // optional string or Service - subject: 'auth', // optional - issuer: 'feathers', // optional - algorithm: 'HS256', // optional - expiresIn: '1d', // optional - secret: null, // required - successRedirect: null, // optional - no default. If set the default success handler will redirect to location - failureRedirect: null, // optional - no default. If set the default success handler will redirect to location - successHandler: null // optional - a middleware to handle things once authentication succeeds - }, - local: { - service: '/auth/local', // optional string or Service - successRedirect: null, // optional - no default. If set the default success handler will redirect to location - failureRedirect: null, // optional - no default. If set the default success handler will redirect to location - successHandler: null, // optional - a middleware to handle things once authentication succeeds - passReqToCallback: true, // optional - whether request should be passed to callback - session: false // optional - whether we should use a session - }, - user: { - service: '/users', // optional string or Service - idField: '_id', // optional - usernameField: 'email', // optional - passwordField: 'password' // optional - }, - oauth2: { - // service: '/auth/facebook', // required - the service path or initialized service - passReqToCallback: true, // optional - whether request should be passed to callback - // callbackUrl: 'callback', // optional - the callback url, by default this gets set to //callback - permissions: { - state: true, - session: false - } - } -}; +export default function init(config = {}) { + const middleware = []; -export default function auth(config = {}) { - return function() { + function authentication() { const app = this; let _super = app.setup; // Merge and flatten options - const authOptions = merge({}, defaults, app.get('auth'), config); + const authOptions = getOptions(app.get('auth'), config); // NOTE (EK): Currently we require token based auth so // if the developer didn't provide a config for our token // provider then we'll set up a sane default for them. - if (!authOptions.token.secret) { - throw new Error (`You must provide a token secret in your config via 'auth.token.secret'.`); + if (!authOptions.secret && !authOptions.token.secret) { + throw new Error (`You must provide a 'secret' in your authentication configuration`); } // Make sure cookies don't have to be sent over HTTPS @@ -85,7 +41,7 @@ export default function auth(config = {}) { // REST middleware if (app.rest && authOptions.setupMiddleware) { debug('registering REST authentication middleware'); - + // Be able to parse cookies it they are enabled if (authOptions.cookie.enable) { app.use(mw.cookieParser()); @@ -95,7 +51,7 @@ export default function auth(config = {}) { app.use(mw.exposeRequestResponse(authOptions)); // Parse token from header, cookie, or request objects app.use(mw.tokenParser(authOptions)); - // Verify and decode a JWT if it is present + // Verify and decode a JWT if it is present app.use(mw.verifyToken(authOptions)); // Make the Passport user available for REST services. app.use(mw.populateUser(authOptions)); @@ -132,12 +88,23 @@ export default function auth(config = {}) { return result; }; + + app.authentication = new Authentication(app, authOptions); + app.authentication.use(... middleware); + } + + authentication.use = function(... mw) { + middleware.push(... mw); + + return authentication; }; + + return authentication; } // Exposed Modules -auth.hooks = hooks; -auth.middleware = mw; -auth.LocalService = local; -auth.TokenService = token; -auth.OAuth2Service = oauth2; +init.hooks = hooks; +init.middleware = mw; +init.LocalService = local; +init.TokenService = token; +init.OAuth2Service = oauth2; diff --git a/src/options.js b/src/options.js new file mode 100644 index 00000000..ba9e0b4b --- /dev/null +++ b/src/options.js @@ -0,0 +1,59 @@ +import merge from 'lodash.merge'; + +// Options that apply to any provider +export const defaults = { + header: 'Authorization', + setupMiddleware: true, // optional - to setup middleware yourself set to false. + cookie: { // Used for redirects, server side rendering and OAuth + enabled: false, // Set to true to enable all cookies + name: 'feathers-jwt', + httpOnly: true, + maxAge: '1d', + secure: true + }, + jwt: { + issuer: 'feathers', + algorithm: 'HS256', + expiresIn: '1d' + }, + token: { + name: 'token', // optional + service: '/auth/token', // optional string or Service + subject: 'auth', // optional + issuer: 'feathers', // optional + algorithm: 'HS256', // optional + expiresIn: '1d', // optional + secret: null, // required + successRedirect: null, // optional - no default. If set the default success handler will redirect to location + failureRedirect: null, // optional - no default. If set the default success handler will redirect to location + successHandler: null // optional - a middleware to handle things once authentication succeeds + }, + local: { + service: '/auth/local', // optional string or Service + successRedirect: null, // optional - no default. If set the default success handler will redirect to location + failureRedirect: null, // optional - no default. If set the default success handler will redirect to location + successHandler: null, // optional - a middleware to handle things once authentication succeeds + passReqToCallback: true, // optional - whether request should be passed to callback + session: false // optional - whether we should use a session + }, + user: { + service: '/users', // optional string or Service + idField: '_id', // optional + usernameField: 'email', // optional + passwordField: 'password', // optional + crypto: 'bcryptjs' + }, + oauth2: { + // service: '/auth/facebook', // required - the service path or initialized service + passReqToCallback: true, // optional - whether request should be passed to callback + // callbackUrl: 'callback', // optional - the callback url, by default this gets set to //callback + permissions: { + state: true, + session: false + } + } +}; + +export default function(... otherOptions) { + return merge({}, defaults, ... otherOptions); +} diff --git a/src/services/authentication.js b/src/services/authentication.js new file mode 100644 index 00000000..b0af324e --- /dev/null +++ b/src/services/authentication.js @@ -0,0 +1,19 @@ +export class Authentication { + create(data, params) { + if(params.provider && !params.authentication) { + return Promise.reject(new Error(`External ${params.provider} requests need to run through an authentication provider`)); + } + + return this.authentication.createJWT(data.payload); + } + + remove(id, params) { + const token = id !== null ? id : params.token; + + return this.authentication.verifyJWT({ token }); + } + + setup(app) { + this.authentication = app.authentication; + } +} diff --git a/src/token/from-request.js b/src/token/from-request.js new file mode 100644 index 00000000..28e96107 --- /dev/null +++ b/src/token/from-request.js @@ -0,0 +1,36 @@ +import Debug from 'debug'; + +const debug = Debug('feathers-authentication:token:from-request'); + +export default function(options) { + const header = options.header; + + if (!header) { + throw new Error(`'header' property must be set in authentication options`); + } + + return function fromRequest(data) { + if (typeof data === 'object' && data.headers) { + const req = data; + + debug('Parsing token from request'); + + // Normalize header capitalization the same way Node.js does + let token = req.headers && req.headers[header.toLowerCase()]; + + // Check the header for the token (preferred method) + if (token) { + // if the value contains "bearer" or "Bearer" then cut that part out + if (/bearer/i.test(token)) { + token = token.split(' ')[1]; + } + + debug('Token found in header'); + } + + return Promise.resolve({ token, req }); + } + + return Promise.resolve(data); + }; +} diff --git a/src/token/index.js b/src/token/index.js new file mode 100644 index 00000000..7030cb93 --- /dev/null +++ b/src/token/index.js @@ -0,0 +1,11 @@ +import fromRequest from './from-request'; +import verifyToken from './verify-token'; +import populateUser from './populate-user'; + +export default { + fromRequest, verifyToken, populateUser +}; + +export const authMiddleware = [ + fromRequest, verifyToken, populateUser +]; diff --git a/src/token/populate-user.js b/src/token/populate-user.js new file mode 100644 index 00000000..9ad33eae --- /dev/null +++ b/src/token/populate-user.js @@ -0,0 +1,39 @@ +import Debug from 'debug'; + +const debug = Debug('feathers-authentication:token:populate-user'); + +export default function(options) { + const app = this; + const { user } = options; + + if(!user.service) { + throw new Error(`'user.service' needs to be set in authentication options`); + } + + return function populateUser(data) { + const service = typeof user.service === 'string' ? app.service(user.service) : user.service; + const idField = user.idField || service.id; + + if(typeof idField !== 'string') { + throw new Error(`'user.idField' needs to be set in authentication options or the '${user.service}' service needs to provide an 'id' field.`); + } + + if(typeof service.get !== 'function') { + throw new Error(`'user.service' does not support a 'get' method necessary for populateUser.`); + } + + if(!data || !data.payload || data.payload[idField] === undefined) { + return Promise.resolve(data); + } + + const id = data.payload[idField]; + + debug(`Populating user ${id}`); + + return service.get(id).then(result => { + const user = result.toJSON ? result.toJSON() : result; + + return Object.assign({}, data, { user }); + }); + }; +} diff --git a/src/token/verify-token.js b/src/token/verify-token.js new file mode 100644 index 00000000..853663ce --- /dev/null +++ b/src/token/verify-token.js @@ -0,0 +1,19 @@ +import Debug from 'debug'; + +const debug = Debug('feathers-authentication:token:verifyToken'); + +export default function() { + return function verifyToken(data) { + const token = typeof data === 'string' ? data : (data && data.token); + const app = this; + + if(token) { + debug('Verifying token', token); + + return app.authentication.verifyJWT(token) + .then(result => Object.assign({ authenticated: true }, data, result)); + } + + return Promise.resolve(data); + }; +} diff --git a/test/src/base.test.js b/test/src/base.test.js new file mode 100644 index 00000000..5f20344f --- /dev/null +++ b/test/src/base.test.js @@ -0,0 +1,84 @@ +import { expect } from 'chai'; +import Authentication from '../../src/base'; +import getOptions from '../../src/options'; + +function timeout(callback, timeout) { + return new Promise(resolve => + setTimeout(() => resolve(callback()), timeout) + ); +} + +describe('Feathers Authentication Base Class', () => { + const original = { name: 'Feathers' }; + const app = {}; + const options = getOptions({ + secret: 'supersecret' + }); + const auth = new Authentication(app, options); + + auth.use(function() { + return function(data) { + expect(this).to.equal(app); + expect(data).to.deep.equal(original); + data.firstRan = true; + + return data; + }; + }); + + it('.authenticate runs middleware', () => { + expect(auth.use(function(opts) { + expect(opts).to.deep.equal(options); + + return function(data) { + expect(data).to.deep + .equal(Object.assign({ firstRan: true }, original)); + + return new Promise(resolve => + setTimeout(() => resolve(Object.assign({ + secondRan: true + }, data)), 100) + ); + }; + })).to.equal(auth); + + return auth.authenticate(original).then(result => + expect(result).to.deep.equal({ + secondRan: true, + name: 'Feathers', + firstRan: true + }) + ); + }); + + it('create and verify', () => { + return auth.createJWT({ + name: 'Eric' + }).then(data => + auth.verifyJWT(data).then(result => { + const { payload } = result; + expect(payload.name).to.equal('Eric'); + expect(payload.exp); + expect(payload.iss).to.equal('feathers'); + }) + ); + }); + + it('verify errors with malformed token', () => { + auth.verifyJWT('invalid token').catch(e => + expect(e.message).to.equal('jwt mlaformed') + ); + }); + + it('create can set options and verify fails with expired token', () => { + return auth.createJWT({ + name: 'Eric' + }, { + expiresIn: '50ms' + }).then(data => + timeout(() => auth.verifyJWT(data), 100) + ).catch(error => + expect(error.message).to.equal('jwt expired') + ); + }); +}); diff --git a/test/src/index.test.js b/test/src/index.test.js index 2d2d486d..5b5bacb4 100644 --- a/test/src/index.test.js +++ b/test/src/index.test.js @@ -207,7 +207,7 @@ describe('Feathers Authentication', () => { const options = { token: { secret: 'secret' - } + } }; app = feathers() @@ -275,7 +275,7 @@ describe('Feathers Authentication', () => { const options = { token: { secret: 'secret' - } + } }; app = feathers() @@ -302,7 +302,7 @@ describe('Feathers Authentication', () => { const options = { token: { secret: 'secret' - } + } }; app = feathers() @@ -338,7 +338,7 @@ describe('Feathers Authentication', () => { setupMiddleware: false, token: { secret: 'secret' - } + } }; app = feathers() @@ -379,4 +379,4 @@ describe('Feathers Authentication', () => { expect(mw.setupPrimusAuthentication).to.not.have.been.called; }); }); -}); \ No newline at end of file +}); diff --git a/test/src/token/from-request.test.js b/test/src/token/from-request.test.js new file mode 100644 index 00000000..d4204a29 --- /dev/null +++ b/test/src/token/from-request.test.js @@ -0,0 +1,52 @@ +import { expect } from 'chai'; +import feathers from 'feathers'; +import authentication from '../../../src'; +import fromRequest from '../../../src/token/from-request'; + +describe('Token Middleware fromRequest', () => { + const app = feathers() + .configure(authentication({ + secret: 'supersecrect' + }).use(fromRequest)); + const auth = app.authentication; + + it('throws an error when header name is not set', () => { + try { + fromRequest({}); + } catch(e) { + expect(e.message).to.equal(`'header' property must be set in authentication options`); + } + }); + + it('passes through when it is not a request object', () => { + auth.authenticate('testtoken').then(data => + expect(data).to.equal('testtoken') + ); + }); + + it('parses basic authorization header', () => { + const mockRequest = { + headers: { + authorization: 'sometoken' + } + }; + + return auth.authenticate(mockRequest).then(data => { + expect(data.req).to.equal(mockRequest); + expect(data.token).to.equal('sometoken'); + }); + }); + + it('parses `Bearer` authorization header', () => { + const mockRequest = { + headers: { + authorization: 'BeaRer sometoken' + } + }; + + return auth.authenticate(mockRequest).then(data => { + expect(data.req).to.equal(mockRequest); + expect(data.token).to.equal('sometoken'); + }); + }); +}); diff --git a/test/src/token/populate-user.test.js b/test/src/token/populate-user.test.js new file mode 100644 index 00000000..698ac718 --- /dev/null +++ b/test/src/token/populate-user.test.js @@ -0,0 +1,53 @@ +import { expect } from 'chai'; +import feathers from 'feathers'; +import authentication from '../../../src'; +import populateUser from '../../../src/token/populate-user'; + +describe('Token Middleware fromRequest', () => { + const app = feathers() + .configure(authentication({ + secret: 'supersecrect', + user: { + idField: 'name' + } + }).use(populateUser)) + .use('/users', { + get(name) { + if(name === 'error') { + return Promise.reject(new Error('User not found')); + } + + return Promise.resolve({ name, username: `Test ${name}` }); + } + }); + const auth = app.authentication; + + it('populates the user from id in payload', () => { + auth.authenticate({ + payload: { name: 'testing' } + }).then(data => + expect(data.user).to.deep.equal({ + name: 'testing', + username: 'Test testing' + }) + ); + }); + + it('errors when id exists but user is not found', () => { + auth.authenticate({ + payload: { name: 'error' } + }).catch(error => + expect(error.message).to.equal('User not found') + ); + }); + + it('does nothing when payload does not exist', () => { + const original = { + dummy: true + }; + + auth.authenticate(original).then(data => + expect(data).to.equal(original) + ); + }); +}); diff --git a/test/src/token/verify-token.test.js b/test/src/token/verify-token.test.js new file mode 100644 index 00000000..7ad26feb --- /dev/null +++ b/test/src/token/verify-token.test.js @@ -0,0 +1,42 @@ +import { expect } from 'chai'; +import feathers from 'feathers'; +import authentication from '../../../src'; +import verifyToken from '../../../src/token/verify-token'; + +describe('Token Middleware verifyToken', () => { + const app = feathers() + .configure(authentication({ + secret: 'supersecrect' + }).use(verifyToken)); + const auth = app.authentication; + + it('passes through when nothing useful is passed', () => { + const dummy = { test: 'me' }; + + return auth.authenticate(dummy).then(data => + expect(data).to.equal(dummy) + ); + }); + + it('.authenticate verifies a token', () => { + const payload = { test: 'data' }; + + return auth.createJWT(payload).then(data => + auth.authenticate(data) + ).then(data => { + expect(data.payload.test).to.equal(payload.test); + expect(data.authenticated).to.equal(true); + }); + }); + + it('.authenticate verifies a string token', () => { + const payload = { test: 'data' }; + + return auth.createJWT(payload).then(data => + auth.authenticate(data.token) + ).then(data => { + expect(data.payload.test).to.equal(payload.test); + expect(data.authenticated).to.equal(true); + }); + }); +});