Skip to content

Commit

Permalink
feature: add cookie option (#81)
Browse files Browse the repository at this point in the history
  • Loading branch information
kamikazechaser authored Mar 21, 2020
1 parent f726c1f commit d753b1c
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 41 deletions.
64 changes: 23 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,70 +220,49 @@ fastify.listen(3000, err => {

#### Example using cookie

Storing JWT in Cookie mean that your app maybe vunerable to XSS attack and must be protected with CSRF token,
consider that as a best practice. but storing JWT on cookies makes your REST API arent Stateless anymore, choose what fit for you.
You can use [crsf](https://www.npmjs.com/package/csrf) or other library that may suit your need.
In some situations you may want to store a token in a cookie. This allows you to drastically reduce the attack surface of XSS on your webapp with the [`httpOnly`](https://wiki.owasp.org/index.php/HttpOnly) and `secure` flags. Cookies can be susceptible to CSRF. You can mitigate this by either setting the [`sameSite`](https://www.owasp.org/index.php/SameSite) flag to `strict`, or by using a CSRF library such as [`fastify-csrf`](https://www.npmjs.com/package/fastify-csrf).

**Note:** This plugin will look for a decorated request with the `cookies` property. [`fastify-cookie`](https://www.npmjs.com/package/fastify-cookie) supports this feature, and therefore you should use it when using the cookie feature. The plugin will fallback to looking for the token in the authorization header if either of the following happens (even if the cookie option is enabled):

- The request has both the authorization and cookie header
- Cookie is empty, authorization header is present

```js
const { readFileSync } = require('fs')
const path = require('path')
const fastify = require('fastify')()
const jwt = require('fastify-jwt')
const Redis = require('ioredis')

// docker run -p 6379:6379 --name redis-test redis
const redis = new Redis({ port: 6379, host: '127.0.0.1' })
const abcache = require('abstract-cache')({
useAwait: false,
driver: {
name: 'abstract-cache-redis', // Must be installed via `npm install`
options: { client: redis }
}
})

fastify.register(jwt, {
secret: {
private: {
key: readFileSync(`${path.join(__dirname, 'certs')}/private.pem`),
passphrase: 'super secret passphrase'
},
public: readFileSync(`${path.join(__dirname, 'certs')}/public.pem`)
},
sign: { algorithm: 'ES256' }
secret: 'foobar'
cookie: {
cookieName: 'token'
}
})

fastify
.register(require('fastify-redis'), { client: redis })
.register(require('fastify-cookie'))
.register(require('fastify-caching'), { cache: abcache })
.register(require('fastify-server-session'), {
secretKey: 'some-secret-password-at-least-32-characters-long',
sessionMaxAge: 900000 // 15 minutes in milliseconds
})

fastify.get('/cookies', async (request, reply) => {
const token = await reply.jwtSign({
name: 'foo',
role: ['admin', 'spy']
// you may registering your csrf here
})

reply
.setCookie('token', token, {
domain: '.domain',
path: '/'
domain: 'your.domain',
path: '/',
secure: true, // send cookie over HTTPS only
httpOnly: true,
sameSite: true // alternative CSRF protection
})
.code(200)
.send('Cookies are send!')
.send('Cookie sent')
})

fastify.get('/verifyCookies', async (request, reply) => {
try {
const verified = await fastify.jwt.verify(request.cookies.token)
reply.code(200).send(verified) // same as above, contain decoded tokens
} catch (err) {
reply.code(401).send(err)
}
fastify.addHook('onRequest', (request) => request.jwtVerify())

fastify.get('/verifycookie', (request, reply) => {
reply.send({ code: 'OK', message: 'it works!' })
})

fastify.listen(3000, err => {
Expand Down Expand Up @@ -467,6 +446,9 @@ fastify.register(require('fastify-jwt'), {
### fastify.jwt.secret
For your convenience, the `secret` you specify during `.register` is made available via `fastify.jwt.secret`. `request.jwtVerify()` and `reply.jwtSign()` will wrap non-function secrets in a callback function. `request.jwtVerify()` and `reply.jwtSign()` use an asynchronous waterfall method to retrieve your secret. It's recommended that your use these methods if your `secret` method is asynchronous.

### fastify.jwt.cookie
For your convenience, `request.jwtVerify()` will look for the token in the cookies property of the decorated request. You must specify `cookieName`. Refer to the [cookie example](https://github.com/fastify/fastify-jwt#example-using-cookie) to see sample usage and important caveats.

### reply.jwtSign(payload, [options,] callback)
### request.jwtVerify([options,] callback)
These methods are very similar to their standard jsonwebtoken counterparts.
Expand Down
15 changes: 15 additions & 0 deletions jwt.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ const {

const messages = {
badRequestErrorMessage: 'Format is Authorization: Bearer [token]',
badCookieRequestErrorMessage: 'Cookie could not be parsed in request',
noAuthorizationInHeaderMessage: 'No Authorization was found in request.headers',
noAuthorizationInCookieMessage: 'No Authorization was found in request.cookies',
authorizationTokenExpiredMessage: 'Authorization token expired',
authorizationTokenInvalid: (err) => `Authorization token is invalid: ${err.message}`,
authorizationTokenUntrusted: 'Untrusted authorization token'
Expand Down Expand Up @@ -52,6 +54,8 @@ function fastifyJwt (fastify, options, next) {
if (typeof secretCallbackSign !== 'function') { secretCallbackSign = wrapStaticSecretInCallback(secretCallbackSign) }
if (typeof secretCallbackVerify !== 'function') { secretCallbackVerify = wrapStaticSecretInCallback(secretCallbackVerify) }

const cookie = options.cookie

const decodeOptions = options.decode || {}
const signOptions = options.sign || {}
const verifyOptions = options.verify || {}
Expand Down Expand Up @@ -84,6 +88,7 @@ function fastifyJwt (fastify, options, next) {
verify: verifyOptions,
messages: messagesOptions
},
cookie: cookie,
secret: secret,
sign: sign,
verify: verify
Expand Down Expand Up @@ -209,6 +214,16 @@ function fastifyJwt (fastify, options, next) {
} else {
return next(new BadRequest(messagesOptions.badRequestErrorMessage))
}
} else if (cookie) {
if (request.cookies) {
if (request.cookies[cookie.cookieName]) {
token = request.cookies[cookie.cookieName]
} else {
return next(new Unauthorized(messagesOptions.noAuthorizationInCookieMessage))
}
} else {
return next(new BadRequest(messagesOptions.badCookieRequestErrorMessage))
}
} else {
return next(new Unauthorized(messagesOptions.noAuthorizationInHeaderMessage))
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
},
"devDependencies": {
"fastify": "^2.0.0",
"fastify-cookie": "^3.5.0",
"standard": "^14.0.2",
"tap": "^12.6.5",
"typescript": "^3.2.2"
Expand Down
235 changes: 235 additions & 0 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,241 @@ test('errors', function (t) {
})
})

test('token in cookie, with fastify-cookie parsing', function (t) {
t.plan(6)

const fastify = Fastify()
fastify.register(jwt, { secret: 'test', cookie: { cookieName: 'jwt' } })
fastify.register(require('fastify-cookie'))

fastify.post('/sign', function (request, reply) {
return reply.jwtSign(request.body)
.then(function (token) {
return { token }
})
})

fastify.get('/verify', function (request, reply) {
return request.jwtVerify()
.then(function (decodedToken) {
return reply.send(decodedToken)
})
})

t.test('token present in cookie', function (t) {
t.plan(2)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: token
}
}).then(function (verifyResponse) {
const decodedToken = JSON.parse(verifyResponse.payload)
t.is(decodedToken.foo, 'bar')
})
})
})

t.test('token absent in cookie', function (t) {
t.plan(2)
fastify.inject({
method: 'get',
url: '/verify',
cookies: {}
}).then(function (verifyResponse) {
const error = JSON.parse(verifyResponse.payload)
t.is(error.message, 'No Authorization was found in request.cookies')
t.is(error.statusCode, 401)
})
})

t.test('both authorization header and cookie present, both valid', function (t) {
t.plan(2)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: token
},
headers: {
authorization: `Bearer ${token}`
}
}).then(function (verifyResponse) {
const decodedToken = JSON.parse(verifyResponse.payload)
t.is(decodedToken.foo, 'bar')
})
})
})

t.test('both authorization and cookie headers present, cookie token value empty (header fallback)', function (t) {
t.plan(2)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: ''
},
headers: {
authorization: `Bearer ${token}`
}
}).then(function (verifyResponse) {
const decodedToken = JSON.parse(verifyResponse.payload)
t.is(decodedToken.foo, 'bar')
})
})
})

t.test('both authorization and cookie headers present, both values empty', function (t) {
t.plan(3)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: ''
},
headers: {
authorization: ''
}
}).then(function (verifyResponse) {
const error = JSON.parse(verifyResponse.payload)
t.is(error.message, 'No Authorization was found in request.cookies')
t.is(error.statusCode, 401)
})
})
})

t.test('both authorization and cookie headers present, header malformed', function (t) {
t.plan(3)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: token
},
headers: {
authorization: 'BearerX'
}
}).then(function (verifyResponse) {
const error = JSON.parse(verifyResponse.payload)
t.is(error.message, 'Format is Authorization: Bearer [token]')
t.is(error.statusCode, 400)
})
})
})
})

test('token in cookie, without fastify-cookie parsing', function (t) {
t.plan(2)

const fastify = Fastify()
fastify.register(jwt, { secret: 'test', cookie: { cookieName: 'jwt' } })

fastify.post('/sign', function (request, reply) {
return reply.jwtSign(request.body)
.then(function (token) {
return { token }
})
})

fastify.get('/verify', function (request, reply) {
return request.jwtVerify()
.then(function (decodedToken) {
return reply.send(decodedToken)
})
})

t.test('token present in cookie, but unparsed', function (t) {
t.plan(3)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: token
}
}).then(function (verifyResponse) {
const error = JSON.parse(verifyResponse.payload)
t.is(error.message, 'Cookie could not be parsed in request')
t.is(error.statusCode, 400)
})
})
})

t.test('both authorization and cookie headers present, cookie uparsed (header fallback)', function (t) {
t.plan(2)
fastify.inject({
method: 'post',
url: '/sign',
payload: { foo: 'bar' }
}).then(function (signResponse) {
const token = JSON.parse(signResponse.payload).token
t.ok(token)

return fastify.inject({
method: 'get',
url: '/verify',
cookies: {
jwt: token
},
headers: {
authorization: `Bearer ${token}`
}
}).then(function (verifyResponse) {
const decodedToken = JSON.parse(verifyResponse.payload)
t.is(decodedToken.foo, 'bar')
})
})
})
})

test('custom response messages', function (t) {
t.plan(5)

Expand Down

0 comments on commit d753b1c

Please sign in to comment.