├── index.js ├── package.json ├── readme.md └── test └── token.test.js /index.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto'); 2 | 3 | var d = exports.defaults = { 4 | cache: true 5 | }; 6 | 7 | var items = {}; 8 | 9 | function cache(key) { 10 | if(!items[key] || !d.cache) { 11 | if(Object.keys(items).length > 500) { 12 | items = {}; 13 | } 14 | items[key] = crypto.createHmac('sha512', d.secret).update(key).digest('base64'); 15 | } 16 | return items[key]; 17 | } 18 | 19 | exports.INVALID = 0; 20 | exports.VALID = 1; 21 | exports.EXPIRING = 2; 22 | 23 | exports.verify = function(data, hash) { 24 | if(typeof data !== 'string' || typeof hash !== 'string' ) { 25 | return false; 26 | } 27 | var epoch = Math.floor(new Date().getTime() / 1000 / d.timeStep); // e.g. http://tools.ietf.org/html/rfc6238 28 | // allow data to be empty, always take into account the time 29 | if (hash === cache(data + epoch) || hash === cache(data + (epoch + 1))) { 30 | return exports.VALID; // truthy, valid and current 31 | } 32 | if (hash === cache(data + (epoch - 1))) { 33 | return exports.EXPIRING; // truthy, expired but still valid 34 | } 35 | return exports.INVALID; 36 | }; 37 | 38 | exports.generate = function(data, opts) { 39 | if(typeof data !== 'string') { 40 | return false; 41 | } 42 | var now = opts && opts.now || (new Date().getTime()), 43 | ts = opts && opts.timeStep || d.timeStep, 44 | secret = opts && opts.secret || d.secret, 45 | epoch = Math.floor(now / 1000 / ts); // e.g. http://tools.ietf.org/html/rfc6238 46 | return crypto.createHmac('sha512', secret).update(data + epoch).digest('base64'); 47 | }; 48 | 49 | exports.invalidate = function(data, hash) { 50 | var isValidHash = exports.verify(data, hash), 51 | epoch = Math.floor(new Date().getTime() / 1000 / d.timeStep); 52 | 53 | if (!isValidHash) { 54 | throw 'invalid hash'; 55 | } else { 56 | items[hash + epoch] = null; 57 | } 58 | 59 | return true; 60 | }; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "token", 3 | "version": "0.1.0", 4 | "description": "HMAC token generation and verification with time-based limitation on validity", 5 | "author": "Mikito Takada ", 6 | "main": "index.js", 7 | "directories": { 8 | "test": "test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git://github.com/mixu/token.git" 13 | }, 14 | "keywords": [ 15 | "authentication", 16 | "auth", 17 | "access", 18 | "token", 19 | "hmac", 20 | "hash", 21 | "time" 22 | ], 23 | "license": "BSD-3-Clause" 24 | } 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # token 2 | 3 | ## Time-limited, HMAC-based authentication token generation 4 | 5 | Basic ideas: 6 | 7 | - Tokens are time-limited 8 | - Tokens can be associated with a set of data 9 | - Token expiry is lax, e.g. clients are warned well in advance of token expiry that they should renew their token 10 | - The verification hashes are cached 11 | 12 | ## API 13 | 14 | - token.generate([data], [opts]): Given a piece of data, generates a sha512 HMAC from the data and the current time (step) using a secret key 15 | - token.verify(data, hash): Given a piece of data and a token, verifies that the HMAC matches. Returns a truthy value, either token.VALID or EXPIRING if the token is valid, or a falsy value, token.INVALID if the token is expired or invalid. 16 | - token.invalidate(data, hash): Given a piece of data, verifies the hash, and invalidates case is valid. 17 | 18 | ## Configuration 19 | 20 | Token is just a small wrapper around sha512 HMAC hashes. 21 | 22 | `token` has the following configuration options: 23 | 24 | - .defaults.secret: A shared secret 25 | - .defaults.timeStep: The length of the time a token is valid. 26 | - .defaults.cache: If false, caching is disabled. 27 | 28 | The server that generates the token, and the server that verifies the token have to agree on these two values. For example: 29 | 30 | var token = require('token'); 31 | token.defaults.secret = 'AAB'; 32 | token.defaults.timeStep = 24 * 60 * 60; // 24h in seconds 33 | 34 | Note that tokens from the previous and next time step are accepted, e.g. tokens can be valid up to three time steps from when they were issued. This allows for 1) the token to expire lazily and 2) for the servers to disagree on time (e.g. even if the generating server is ahead, the token will be accepted). 35 | 36 | Caching: only the verification code uses a simple cache. Hashes are looked up from memory, and only computed if they were not previously computed. Up to 500 hashes are stored and when the cache is full, it is emptied completely. 37 | 38 | ## Passing in data and verifying the token 39 | 40 | The idea is that you can take any arbitrary data, and make it part of the token hash. 41 | 42 | This allows you to make sure that the token is valid and that the data associated with the token is trustworthy. 43 | 44 | For example, you might generate a token like this: 45 | 46 | JSON.stringify( { id: 1, role: 'admin', auth: token.generate('1|admin') }); 47 | 48 | Then, to verify that token, you need the id and role attributes as well as the actual token hash. 49 | 50 | The token will only validate if the id and role match (and the token timestamp is up to date, which is implicitly included): 51 | 52 | function isValid(json) { 53 | return token.verify(json.id+'|'+json.role, json.auth); 54 | } 55 | 56 | Note that if you put data in the token, you will need to recreate the data argument when you verify the token. 57 | 58 | ## Token expiry 59 | 60 | Expiry is lax: tokens from the previous time step are accepted. 61 | 62 | The reason for having lax expiry is that it makes clients simpler: assuming that the token expiry is sufficiently long, clients do not need to handle edge cases around when the token expires. 63 | 64 | Instead, when the clients send tokens that are old (e.g. expired one time step ago), the tokens are still accepted but the client is warned that it should get a new token soon. 65 | 66 | ## Links 67 | 68 | - HOTP (HMAC-Based One-Time Password Algorithm): [RFC 4226](http:tools.ietf.org/html/rfc4226) 69 | - TOTP (Time-Based One-Time Password Algorithm): [RFC 6238](http:tools.ietf.org/html/rfc6238) 70 | 71 | -------------------------------------------------------------------------------- /test/token.test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | 3 | Auth = require('../index.js'); 4 | 5 | Auth.defaults.secret = 'AAB'; 6 | Auth.defaults.timeStep = 24 * 60 * 60; // 24h in seconds 7 | 8 | exports['valid token should be accepted'] = function() { 9 | var hmac = Auth.generate('foo'); 10 | assert.equal(Auth.verify('foo', hmac), Auth.VALID); 11 | }; 12 | 13 | exports['invalid token should be rejected'] = function() { 14 | var hmac = Auth.generate('foo', { secret: 'abc' }); 15 | assert.equal(Auth.verify('foo', hmac), Auth.INVALID); 16 | }; 17 | 18 | exports['expired token should be rejected'] = function() { 19 | function ep(d) { return Math.round(d / 1000 / Auth.defaults.timeStep); } 20 | var epoch = ep(new Date().getTime()), 21 | old = (epoch - 1) * 1000 * Auth.defaults.timeStep - 1, 22 | expired = (epoch - 2) * 1000 * Auth.defaults.timeStep - 1; 23 | assert.equal(Auth.verify('foo', Auth.generate('foo', { now: old })), Auth.EXPIRING); 24 | assert.equal(Auth.verify('foo', Auth.generate('foo', { now: expired })), Auth.INVALID); 25 | }; 26 | 27 | exports['next expiry'] = function() { 28 | 29 | var epoch = Math.floor(new Date().getTime() / 1000 / Auth.defaults.timeStep), 30 | started = new Date(epoch * Auth.defaults.timeStep * 1000), 31 | ends = new Date( (epoch + 1) * Auth.defaults.timeStep * 1000), 32 | untilEnd = ends.getTime() - new Date().getTime(); 33 | 34 | var seconds = Math.floor(untilEnd / 1000), 35 | minutes = Math.floor(seconds / 60), 36 | hours = Math.floor(minutes / 60), 37 | days = Math.floor(hours / 24); 38 | 39 | seconds -= minutes * 60; 40 | minutes -= hours * 60; 41 | hours -= days * 24; 42 | 43 | console.log('Now: ' + new Date()); 44 | console.log('Started: ' + started); 45 | console.log('Ends: ' + ends); 46 | console.log(days + ' days ' + hours + 'h ' +minutes + 'm ' + seconds + 's'); 47 | }; 48 | 49 | exports['bench'] = function() { 50 | this.timeout(5000); 51 | var until = new Date().getTime() + 2000, 52 | uncached = 0, 53 | cached = 0, 54 | token = Auth.generate('foo'); 55 | 56 | Auth.defaults.cache = false; 57 | while(new Date().getTime() < until) { 58 | Auth.verify( 'foo', token ); 59 | uncached++; 60 | } 61 | console.log('Uncached: ', uncached / 2 + ' hashes per second'); 62 | 63 | Auth.defaults.cache = true; 64 | until = new Date().getTime() + 2000; 65 | while(new Date().getTime() < until) { 66 | Auth.verify( 'foo', token ); 67 | cached++; 68 | } 69 | console.log('Cached: ', cached / 2 + ' hashes per second'); 70 | }; 71 | 72 | // if this module is the script being run, then run the tests: 73 | if (module == require.main) { 74 | var mocha = require('child_process').spawn('mocha', [ '--colors', '--ui', 'exports', '--reporter', 'spec', __filename ]); 75 | mocha.stdout.pipe(process.stdout); 76 | mocha.stderr.pipe(process.stderr); 77 | } 78 | --------------------------------------------------------------------------------