diff --git a/README.md b/README.md index d880486..40ead81 100644 --- a/README.md +++ b/README.md @@ -5,28 +5,20 @@ React JWT user store ## Usage ```javascript -let React = require('react'), - userStore = require('react-jwt-store')(); - -class someComponent extends React.Component { - constructor() { - super(props); - - this.state = { - user: userStore.getUser(), - token: userStore.getToken() - }; - } - render() { - let user = this.state.user, - token = this.state.token; - - return ( -
-

{user}

-

{token}

- } -} +const userStore = require('react-jwt-store')() + +userStore.on('Token received', (token, user) => { + console.log(token, user) +}) + +userStore.init() +``` + +### Initialize +In order to trigger the store's refresh mechanism and send data to any event +handlers, you must call the `init` method. +```javascript +userStore.init() ``` ### Set the token @@ -35,6 +27,12 @@ You can set the token without interacting with cookies via the following. userStore.setToken('jwt') ``` +### Refresh the token +You can force a refresh of the token via the following. +```javascript +userStore.refreshToken() +``` + ### Override Cookie Key By default, the JWT assumes the cookie key is `XSRF-TOKEN`. This can be overridden @@ -43,3 +41,20 @@ by passing `cookie` on the `options` hash: ```javascript let userStore = require('react-jwt-store')({ cookie: 'NOT-XSRF-TOKEN'}); ``` + +### Set a logger + +By default, the store does not log anything, but if you pass in a `console` +compatible logger, the store will log the state of the token as it changes. + +### Terminate + +By default, if you set token to null: +```javascript +userStore.setToken(null) +``` +...an interval responsible for refreshing tokens (the one set up on init) will not be cleared. +If you want to clear it explicitly, then you can do it with: +```javascript +userStore.terminate() +``` diff --git a/package.json b/package.json index e9f1e38..869a9ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-jwt-store", - "version": "1.2.1", + "version": "2.0.0", "description": "React JWT store", "license": "MIT", "author": "Seth Carney", @@ -42,9 +42,6 @@ "local-storage": "^1.4.2", "xtend": "^4.0.0" }, - "babel": { - "stage": 0 - }, "standard": { "parser": "babel-eslint", "globals": [ diff --git a/src/index.js b/src/index.js index d20dce7..7f54a0b 100644 --- a/src/index.js +++ b/src/index.js @@ -7,70 +7,86 @@ import events from 'events' import ls from 'local-storage' const EventEmitter = events.EventEmitter - -const canWarn = () => console && console.warn && typeof console.warn === 'function' - -const decodeToken = token => { - if (token) { - try { - return decode(token) - } catch (e) { - canWarn() && console.warn(`Invalid JWT: ${token}`) - return void 0 - } - } -} +const noop = function () { } module.exports = (options) => { - options = extend({ cookie: 'XSRF-TOKEN' }, options) - - let token = options.localStorageKey - ? ls.get(options.localStorageKey) - : cookie.get && cookie.get(options.cookie) - let user + let token + let refreshTimer - const tokenStore = extend({ - getToken () { - return token - }, - - getUser () { - return user - }, + options = extend({ cookie: 'XSRF-TOKEN' }, options) - getUserId () { - return user ? user.id : void 0 - }, + const logger = options.logger || { + info: noop, + warn: noop + } - setToken (newToken) { - token = newToken - user = decodeToken(token) - this.emit('Token received') + const decodeToken = token => { + if (token) { + try { + return decode(token) + } catch (e) { + logger.warn(`[JWT store] Invalid JWT: ${token}`) + return void 0 + } } - }, EventEmitter.prototype) + } + + const refreshInterval = options.refreshInterval + ? options.refreshInterval + : (60000) const refreshToken = () => { if (!token && options.refresh) { - options.refresh() - .then(tokenStore.setToken.bind(tokenStore)) + tokenStore.refreshToken() } else { user = decodeToken(token) } let expDate = user ? new Date(user.exp * 1000 - 2 * refreshInterval) : null if (expDate && expDate < new Date() && options.refresh) { - options.refresh() - .then(tokenStore.setToken.bind(tokenStore)) + tokenStore.refreshToken() } } - const refreshInterval = options.refreshInterval - ? options.refreshInterval - : (60000) - refreshToken() + const tokenStore = extend({ + init () { + let token + if (options.localStorageKey) { + try { + token = ls.get(options.localStorageKey) + } catch (e) { + logger.warn('[JWT store] Unable to get token', e) + } + } else { + token = cookie.get && cookie.get(options.cookie) + } + + if (token) { this.setToken(token) } + refreshToken() + + refreshTimer = setInterval(refreshToken, refreshInterval) + }, - setInterval(refreshToken, refreshInterval) + setToken (newToken) { + logger.info('[JWT store] setting new token', newToken) + token = newToken + user = decodeToken(token) + this.emit('Token received', token, user) + }, + + refreshToken () { + logger.info('[JWT store] refreshing token', token) + options.refresh(token) + .then(tokenStore.setToken.bind(tokenStore)) + }, + + terminate () { + user = undefined + token = undefined + clearInterval(refreshTimer) + } + }, EventEmitter.prototype) return tokenStore } diff --git a/test/data/token-timezone.json b/test/data/token-timezone.json new file mode 100644 index 0000000..42e1d7f --- /dev/null +++ b/test/data/token-timezone.json @@ -0,0 +1 @@ +"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6Mjc1MTA1NSwidXNlcl9pZCI6Mjc1MTA1NSwibG9naW5faWQiOjIzMSwib3JnYW5pemF0aW9uX2lkIjoyNzMxMTExLCJvcmdhbml6YXRpb25fbmFtZSI6IkRFVjogRGV2ZWxvcG1lbnQgRW52aXJvbm1lbnQiLCJlbWFpbCI6Im1pa2UuYXRraW5zQGxhbmV0aXguY29tIiwiZmlyc3RfbmFtZSI6Ik1pa2UiLCJsYXN0X25hbWUiOiJBdGtpbnMiLCJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9zZWN1cmUubGFuZXRpeC5jb20vY3NzL2ltYWdlcy9kZWZhdWx0LWF2YXRhci5wbmciLCJwZXJtaXNzaW9ucyI6WyJsYW5ldGl4LXN0YWZmIiwiY3VzdG9tZXItYWRtaW5pc3RyYXRvciIsImFuYWx5dGljcyJdLCJ0aW1lem9uZSI6IlVUQyIsImZlYXR1cmVzIjp7fSwiaWF0IjoxNDI5NjUzNDI3LCJleHAiOjE0NjEwMTk0MTguNjQ5LCJhdWQiOiJ1cm46bGFuZXRpeC9hcGkiLCJpc3MiOiJ1cm46bGFuZXRpeC9hdXRoIiwic3ViIjoibWlrZS5hdGtpbnNAbGFuZXRpeC5jb20ifQ.kOAGDWy2lYSKTQSn2_jKSXJYRlPMJl8wWAJQSbgFQBM" diff --git a/test/index.js b/test/index.js index 6d883eb..d58940a 100644 --- a/test/index.js +++ b/test/index.js @@ -4,6 +4,7 @@ import assert from 'assert' import { btoa } from 'Base64' import decode from 'jwt-decode' import token from './data/token' +import tokenTimezone from './data/token-timezone' import ls from 'local-storage' import bluebird from 'bluebird' @@ -21,10 +22,10 @@ describe('Token Store', () => { beforeEach(() => { // HACK around https://github.com/auth0/jwt-decode/issues/5 - GLOBAL.window = GLOBAL + global.window = global // HACK around cookie monster returning undefined when document isn't there - GLOBAL.document = {} + global.document = {} }) beforeEach(() => { @@ -32,41 +33,62 @@ describe('Token Store', () => { }) afterEach(() => { - delete GLOBAL.window - delete GLOBAL.document + delete global.window + delete global.document ls.remove(localStorageKey) }) it('should set user after no token is present', () => { const tokenStore = require('../src')() - tokenStore.setToken(token) - let user = tokenStore.getUser() + tokenStore.on('Token received', (_, user) => { + assert.equal(user.first_name, 'Mike') + assert.equal(user.last_name, 'Atkins') + }) - assert.equal(user.first_name, 'Mike') - assert.equal(user.last_name, 'Atkins') + tokenStore.init() + tokenStore.setToken(token) }) it('should get the token out of local storage', () => { ls.set(localStorageKey, token) const tokenStore = require('../src')({localStorageKey}) - const user = tokenStore.getUser() + tokenStore.on('Token received', (_, user) => { + assert.equal(user.first_name, 'Mike') + assert.equal(user.last_name, 'Atkins') + }) + tokenStore.init() + }) - assert.equal(user.first_name, 'Mike') - assert.equal(user.last_name, 'Atkins') + it('should catch an exception token is not present in local storage', () => { + ls.set(localStorageKey, undefined) + const tokenStore = require('../src')({localStorageKey}) + tokenStore.on('Token received', assert.fail) + tokenStore.init() }) it('if no token call refresh & set token', done => { const tokenStore = require('../src')({refresh: () => bluebird.resolve(updatedToken) }) - tokenStore.on('Token received', () => { - const user = tokenStore.getUser() - + tokenStore.on('Token received', (_, user) => { assert.equal(user.first_name, 'Mike') assert.equal(user.last_name, 'Atkins') done() }) + tokenStore.init() + }) + + it('if token is expired, call refresh with expired token', done => { + ls.set(localStorageKey, token) + require('../src')({ + localStorageKey, + refresh: (t) => { + assert.equal(t, token) + done() + return bluebird.resolve(updatedToken) + } + }).init() }) it('if token is expired, call refresh & set token', done => { @@ -76,13 +98,13 @@ describe('Token Store', () => { refresh: () => bluebird.resolve(updatedToken) }) - tokenStore.on('Token received', () => { - const user = tokenStore.getUser() - + let callCount = 0 + tokenStore.on('Token received', (_, user) => { assert.equal(user.first_name, 'Mike') assert.equal(user.last_name, 'Atkins') - done() + if (++callCount === 2) { done() } }) + tokenStore.init() }) it('if token valid, leave as is', () => { @@ -91,36 +113,58 @@ describe('Token Store', () => { localStorageKey, refresh: () => assert.fail('should not be called') }) - - const user = tokenStore.getUser() - - assert.equal(user.first_name, 'Mike') - assert.equal(user.last_name, 'Atkins') + tokenStore.on('Token received', (_, user) => { + assert.equal(user.first_name, 'Mike') + assert.equal(user.last_name, 'Atkins') + }) + tokenStore.init() }) - it('it token to expire soon, refresh after interval', done => { + it('if token to expire soon, refresh after interval', done => { ls.set(localStorageKey, updatedToken) const tokenStore = require('../src')({ localStorageKey, refresh: () => bluebird.resolve(updatedToken), refreshInterval: 1000 }) - tokenStore.on('Token received', () => { - const user = tokenStore.getUser() - + let callCount = 0 + tokenStore.on('Token received', (_, user) => { assert.equal(user.first_name, 'Mike') assert.equal(user.last_name, 'Atkins') - done() + if (++callCount === 2) { done() } }) + tokenStore.init() + }) + + it('refreshes the token and sets it', done => { + ls.set(localStorageKey, setTokenExp(Date.now() + 100 * 60 * 1000)) + const tokenStore = require('../src')({ + localStorageKey, + refresh: () => bluebird.resolve(tokenTimezone) + }) + + let callCount = 0 + tokenStore.on('Token received', (_, user) => { + callCount++ + if (callCount === 1) { + assert(!user.timezone) + } else if (callCount === 2) { + assert.equal(user.timezone, 'UTC') + done() + } else { + assert.fail('shouldn\'t be called more than twice') + } + }) + + tokenStore.init() + tokenStore.refreshToken() }) describe('sad path', () => { it('should not blow up when cookie is not present', () => { let tokenStore assert.doesNotThrow(() => tokenStore = require('../src')()) - assert.ok(tokenStore.getToken() === void 0) - assert.ok(tokenStore.getUser() === void 0) - assert.ok(tokenStore.getUserId() === void 0) + assert.doesNotThrow(() => tokenStore.init()) }) it('should not blow up when cookie is invalid', () => { @@ -129,54 +173,103 @@ describe('Token Store', () => { let tokenStore assert.doesNotThrow(() => tokenStore = require('../src')()) - assert.ok(tokenStore.getToken() === token) - assert.ok(tokenStore.getUser() === void 0) - assert.ok(tokenStore.getUserId() === void 0) + assert.doesNotThrow(() => tokenStore.init()) }) }) describe('default cookie key', () => { - let tokenStore + let tokenFromStore + let user beforeEach(() => { require('cookie-monster').set('XSRF-TOKEN', token) - tokenStore = require('../src')() + const tokenStore = require('../src')() + tokenStore.on('Token received', (t, u) => { + tokenFromStore = t + user = u + }) + tokenStore.init() }) it('should get the XSRF-TOKEN and return it', () => { - assert.equal(tokenStore.getToken(), token) + assert.equal(tokenFromStore, token) }) it('should return user', () => { - let user = tokenStore.getUser() - assert.equal(user.first_name, 'Mike') assert.equal(user.last_name, 'Atkins') }) - - it('should return user id', () => { - let id = tokenStore.getUserId() - assert.equal(id, 2751055) - }) - - it('should return token', () => { - let t = tokenStore.getToken() - assert.equal(token, t) - }) }) describe('override cookie key', () => { - let tokenStore + let tokenFromStore beforeEach(() => { require('cookie-monster').set('NOT-XSRF-TOKEN', token) - tokenStore = require('../src')({ cookie: 'NOT-XSRF-TOKEN' }) + const tokenStore = require('../src')({ cookie: 'NOT-XSRF-TOKEN' }) + tokenStore.on('Token received', (t) => { + tokenFromStore = t + }) + tokenStore.init() }) it('should get the NOT-XSRF-TOKEN and return it', () => { - assert.equal(tokenStore.getToken(), token) + assert.equal(tokenFromStore, token) + }) + }) + + describe('terminate', () => { + let cookieMonster + beforeEach(() => { + cookieMonster = require('cookie-monster') + cookieMonster.set('XSRF-TOKEN', token) + }) + it('should set token to undefined on explicit termination', done => { + let callCount = 0 + const tokenStore = require('../src')({ + refresh: (t) => { + if (callCount === 0) { + cookieMonster.set('XSRF-TOKEN', token, {expires: 'Thu, 01 Jan 1970 00:00:01 GMT'}) + assert.equal(t, token) + } + if (callCount === 1) { + assert.equal(t, undefined) + } + callCount++ + + return bluebird.resolve(t) + } + }) + tokenStore.init() + tokenStore.terminate() + tokenStore.refreshToken() + done() + }) + + it('should not set token to undefined when no explicit termination', done => { + let callCount = 0 + const tokenStore = require('../src')({ + refresh: (t) => { + if (!t) { + return bluebird.resolve() + } + if (callCount === 0) { + cookieMonster.set('XSRF-TOKEN', token, {expires: 'Thu, 01 Jan 1970 00:00:01 GMT'}) + assert.equal(t, token) + } + if (callCount === 1) { + assert.equal(t, token) + } + callCount++ + + return bluebird.resolve(t) + } + }) + tokenStore.init() + tokenStore.refreshToken() + done() }) }) })