diff --git a/README.md b/README.md index c08ca13..df16b96 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![NPM version][npm-image]][npm-url] [![Build Status][travis-image]][travis-url] [![Dependency Status][daviddm-image]][daviddm-url] -An electrode plugin that enables stateless CSRF protection using [JWT](https://github.com/auth0/node-jsonwebtoken) in Electrode, Express, or Hapi applications. +An electrode plugin that enables stateless CSRF protection using [JWT](https://github.com/auth0/node-jsonwebtoken) in Electrode, Express, Hapi, or Koa 2 applications. ## Why do we need this module? @@ -55,7 +55,7 @@ Others are optional and follow the [same usage as jsonwebtoken](https://github.c * `noTimestamp` * `headers` -This module can be used with either [Electrode](#electrode), [Express](#express), or [Hapi](#hapi). +This module can be used with either [Electrode](#electrode), [Express](#express), [Hapi](#hapi), or [Koa 2](#koa-2). ### Electrode @@ -113,6 +113,24 @@ server.register({register: csrfPlugin, options}, (err) => { }); ``` +### Koa 2 + +#### Example `app.js` configuration + +```js +const csrfMiddleware = require("electrode-csrf-jwt").koaMiddleware; +const Koa = require("koa"); + +const app = new Koa(); + +const options = { + secret: "shhhhh", + expiresIn: 60 +}; + +app.use(csrfMiddleware(options)); +``` + Built with :heart: by [Team Electrode](https://github.com/orgs/electrode-io/people) @WalmartLabs. [npm-image]: https://badge.fury.io/js/electrode-csrf-jwt.svg diff --git a/lib/csrf-koa.js b/lib/csrf-koa.js new file mode 100644 index 0000000..fcc54e3 --- /dev/null +++ b/lib/csrf-koa.js @@ -0,0 +1,61 @@ +"use strict"; + +const Promise = require("bluebird"); +const uuid = require("uuid"); +const csrf = require("./csrf"); + +const MISSING_SECRET = "MISSING_SECRET"; +const INVALID_JWT = "INVALID_JWT"; + +function csrfMiddleware(options) { + if (!options || !options.secret) { + throw new Error(MISSING_SECRET); + } + + function middleware(ctx, next) { + + function createToken() { + const id = uuid.v4(); + const headerPayload = {type: "header", uuid: id}; + const cookiePayload = {type: "cookie", uuid: id}; + + return Promise.all([ + csrf.create(headerPayload, options), + csrf.create(cookiePayload, options) + ]).spread((headerToken, cookieToken) => { + ctx.set("x-csrf-jwt", headerToken); + ctx.cookies.set("x-csrf-jwt", cookieToken); + return next(); + }); + } + + function verifyAndCreateToken() { + const headerPayload = {token: ctx.headers["x-csrf-jwt"], secret: options.secret}; + const cookiePayload = {token: ctx.cookies.get("x-csrf-jwt"), secret: options.secret}; + + return Promise.all([ + csrf.verify(headerPayload), + csrf.verify(cookiePayload) + ]).spread((headerToken, cookieToken) => { + if (headerToken.uuid === cookieToken.uuid && + headerToken.type === "header" && cookieToken.type === "cookie") { + return createToken(); + } + ctx.throw(new Error(INVALID_JWT)); + }).catch((err) => { + ctx.throw(err); + }); + } + + const method = ctx.method.toUpperCase(); + if (method !== "GET" && method !== "HEAD") { + return verifyAndCreateToken(); + } + + return createToken(); + } + + return middleware; +} + +module.exports = csrfMiddleware; diff --git a/lib/index.js b/lib/index.js index 9a39ef1..01bc133 100644 --- a/lib/index.js +++ b/lib/index.js @@ -2,8 +2,10 @@ const csrfHapi = require("./csrf-hapi"); const csrfExpress = require("./csrf-express"); +const csrfKoa = require("./csrf-koa"); module.exports = { register: csrfHapi, - expressMiddleware: csrfExpress + expressMiddleware: csrfExpress, + koaMiddleware: csrfKoa }; diff --git a/package.json b/package.json index d8de1a4..8b5fa43 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "lint": "gulp lint", "test": "npm run lint && gulp test", - "coverage": "istanbul cover -x lib/csrf-express.js node_modules/.bin/_mocha", + "coverage": "istanbul cover -x lib/csrf-express.js lib/csrf-koa.js node_modules/.bin/_mocha", "prepublish": "npm test" }, "repository": { @@ -41,6 +41,9 @@ "hapi": "^14.2.0", "isomorphic-fetch": "^2.2.1", "istanbul": "^0.4.5", + "koa": "^2.0.0", + "koa-bodyparser": "^3.2.0", + "koa-router": "^7.0.1", "mocha": "^3.0.2", "vision": "^4.0.1" } diff --git a/test/spec/csrf-koa-test.js b/test/spec/csrf-koa-test.js new file mode 100644 index 0000000..92f2b79 --- /dev/null +++ b/test/spec/csrf-koa-test.js @@ -0,0 +1,130 @@ +"use strict"; + +const bodyParser = require("koa-bodyparser"); +const Router = require("koa-router"); +const Koa = require("koa"); +const csrfMiddleware = require("../../lib/index").koaMiddleware; +const jwt = require("jsonwebtoken"); + +const fetch = require("isomorphic-fetch"); + +const secret = "test"; +const url = "http://localhost:4000"; +let server; + +describe("test register", () => { + it("should fail with bad options", () => { + const app = new Koa(); + try { + app.use(csrfMiddleware()); + } catch (e) { + expect(e.message).to.equal("MISSING_SECRET"); + } + }); +}); + +describe("test csrf-jwt koa middleware", () => { + before(() => { + const app = new Koa(); + const router = new Router(); + + app.use(bodyParser()); + + const options = { + secret, + expiresIn: "2d", + ignoreThisParam: "ignore" + }; + app.use(csrfMiddleware(options)); + + router.get("/1", (ctx) => { + ctx.body = "valid"; + }); + + router.post("/2", (ctx) => { + expect(ctx.request.body.message).to.equal("hello"); + ctx.body = "valid"; + }); + + app.use(router.routes()); + + server = require("http").createServer(app.callback()); + server.listen(4000); + }); + + after(() => { + server.close(); + }); + + it("should return success", () => { + return fetch(`${url}/1`) + .then((res) => { + expect(res.status).to.equal(200); + const csrfHeader = res.headers.get("x-csrf-jwt"); + const csrfCookie = res.headers.get("set-cookie"); + expect(csrfHeader).to.exist; + expect(csrfCookie).to.contain("x-csrf-jwt="); + + return fetch(`${url}/2`, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "x-csrf-jwt": csrfHeader, + "Cookie": csrfCookie + }, + body: JSON.stringify({message: "hello"}) + }).then((res) => { + expect(res.status).to.equal(200); + expect(res.headers.get("x-csrf-jwt")).to.exist; + expect(res.headers.get("set-cookie")).to.contain("x-csrf-jwt="); + }); + }); + }); + + it("should return 500 for missing jwt", () => { + return fetch(`${url}/2`, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + body: JSON.stringify({message: "hello"}) + }).then((res) => { + expect(res.status).to.equal(500); + expect(res.headers.get("x-csrf-jwt")).to.not.exist; + expect(res.headers.get("set-cookie")).to.not.exist; + }); + }); + + it("should return 500 for invalid jwt", () => { + return fetch(`${url}/1`) + .then(() => { + const token = jwt.sign({uuid: "1"}, secret, {}); + return fetch(`${url}/2`, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "x-csrf-jwt": token, + "Cookie": `x-csrf-jwt=${token}` + }, + body: JSON.stringify({message: "hello"}) + }).then((res) => { + expect(res.status).to.equal(500); + return fetch(`${url}/2`, { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "x-csrf-jwt": "invalid", + "Cookie": `x-csrf-jwt=${token}` + }, + body: JSON.stringify({message: "hello"}) + }).then((res) => { + expect(res.status).to.equal(500); + }); + }); + }); + }); +});