mirror of
https://github.com/scratchfoundation/scratch-auth.git
synced 2025-06-17 06:00:20 -04:00
Initial commit
This commit is contained in:
parent
9d5423f22a
commit
3534c204d0
16 changed files with 472 additions and 9 deletions
3
.eslintignore
Normal file
3
.eslintignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules/*
|
||||
coverage/*
|
||||
.nyc_output/*
|
10
.eslintrc.js
Normal file
10
.eslintrc.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
extends: ['scratch', 'scratch/node'],
|
||||
env: {
|
||||
node: true,
|
||||
es6: true
|
||||
},
|
||||
rules: {
|
||||
'no-div-regex': [0]
|
||||
}
|
||||
};
|
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
# MacOS
|
||||
.DS_Store
|
||||
|
||||
# NPM
|
||||
/node_modules
|
||||
npm-*
|
||||
|
||||
# Code coverage
|
||||
/coverage
|
||||
/.nyc_output
|
5
.npmrc
5
.npmrc
|
@ -1,5 +0,0 @@
|
|||
engine-strict=true
|
||||
save-exact=true
|
||||
save-prefix=~
|
||||
init-license=BSD-3-Clause
|
||||
init-author-name=Massachusetts Institute of Technology
|
8
.travis.yml
Normal file
8
.travis.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
language: node_js
|
||||
node_js:
|
||||
- 'stable'
|
||||
- 'latest'
|
||||
sudo: false
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
16
README.md
Normal file
16
README.md
Normal file
|
@ -0,0 +1,16 @@
|
|||
## scratch-auth
|
||||
#### Utilities for authenticating Scratch users.
|
||||
|
||||
## Installation
|
||||
```bash
|
||||
npm install git@github.com:LLK/scratch-auth.git
|
||||
```
|
||||
|
||||
## Basic Use
|
||||
```js
|
||||
const Auth = require('scratch-auth');
|
||||
const a = new Auth('salt', 'secret');
|
||||
|
||||
const unsigned = a.unsign('sometoken');
|
||||
const unpacked = a.unpack(unsigned);
|
||||
```
|
72
index.js
Normal file
72
index.js
Normal file
|
@ -0,0 +1,72 @@
|
|||
const pako = require('pako');
|
||||
|
||||
const signer = require('./lib/signer');
|
||||
const util = require('./lib/util');
|
||||
|
||||
class Auth {
|
||||
constructor (salt, secret) {
|
||||
this.salt = salt;
|
||||
this.secret = secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of django.core.signing.signer.unsign
|
||||
* @param {string} signedString String in the form value:time:signature
|
||||
* @return {string} Unsigned string
|
||||
*/
|
||||
unsign (signedString) {
|
||||
// Validate
|
||||
if (typeof signedString !== 'string') return;
|
||||
if (signedString.indexOf(':') === -1) return;
|
||||
|
||||
// Decode
|
||||
var components = signedString.split(':');
|
||||
var value = components.slice(0, -1).join(':');
|
||||
var signature = components.slice(-1)[0];
|
||||
var challenge = signer.base64Hmac(this.salt, value, this.secret);
|
||||
|
||||
// Compare signature to challenge
|
||||
if (util.md5(signature) !== util.md5(challenge)) return;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the usable content portion of a signed, compressed cookie
|
||||
* generated by Django's signing module
|
||||
* See: github.com/django/django/blob/stable/1.8.x/django/core/signing.py
|
||||
* @param {string} s Signed (and optionally compressed) cookie
|
||||
* @return {object} Unpacked cookie
|
||||
*/
|
||||
unpack (s) {
|
||||
// Validate
|
||||
if (typeof s !== 'string') return;
|
||||
|
||||
// Storage objects
|
||||
const decompress = (s[0] === '.');
|
||||
const b64data = s.split(':')[0];
|
||||
|
||||
// Base64 decode
|
||||
var result = util.b64Decode(b64data);
|
||||
|
||||
try {
|
||||
// Handle decompression
|
||||
if (decompress) {
|
||||
var charData = result.split('').map(function (c) {
|
||||
return c.charCodeAt(0);
|
||||
});
|
||||
var binData = new Uint8Array(charData);
|
||||
var data = pako.inflate(binData);
|
||||
result = String.fromCharCode.apply(null, new Uint16Array(data));
|
||||
}
|
||||
|
||||
// Convert to object
|
||||
result = JSON.parse(result);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Auth;
|
65
lib/signer.js
Normal file
65
lib/signer.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
const crypto = require('crypto');
|
||||
const util = require('./util');
|
||||
|
||||
const signer = module.exports = {
|
||||
/**
|
||||
* Method for obtaining the key for the HMAC-SHA1.
|
||||
* @param {string} salt Crypto salt
|
||||
* @param {string} secret Crypto secret
|
||||
* @return {Buffer} Binary SHA1 digest of the salt and secret
|
||||
*/
|
||||
getSaltedHmacKey: function (salt, secret) {
|
||||
// Validate
|
||||
if (typeof salt !== 'string') return;
|
||||
if (typeof secret !== 'string') return;
|
||||
|
||||
// Create SHA1 hash
|
||||
var keySha1Sum = crypto.createHash('sha1');
|
||||
keySha1Sum.update(salt + secret, 'binary');
|
||||
return keySha1Sum.digest('binary');
|
||||
},
|
||||
|
||||
/**
|
||||
* Port of django.utils.crypto.salted_hmac:
|
||||
* Returns the HMAC-SHA1 of `value`, using a key generated from the
|
||||
* specified salt and secret.
|
||||
* @param {string} salt Crypto salt
|
||||
* @param {string} value Value to be transformed
|
||||
* @param {string} secret Crypto secret
|
||||
* @return {object} HMAC object
|
||||
*/
|
||||
getSaltedHmac: function (salt, value, secret) {
|
||||
// Validate
|
||||
if (typeof salt !== 'string') return;
|
||||
if (typeof value !== 'string') return;
|
||||
if (typeof secret !== 'string') return;
|
||||
|
||||
// Return HMAC-SHA1 of the specified `value`
|
||||
var hmac = crypto.createHmac(
|
||||
'sha1',
|
||||
new Buffer(signer.getSaltedHmacKey(salt, secret), 'binary')
|
||||
);
|
||||
hmac.update(value, 'binary');
|
||||
return hmac;
|
||||
},
|
||||
|
||||
/**
|
||||
* Port of django.core.signing.base64_hmac:
|
||||
* Returns a URL-safe Base64 encoded representation of the digest of the
|
||||
* HMAC of `value`.
|
||||
* @param {string} salt Crypto salt
|
||||
* @param {string} value Value to be transformed
|
||||
* @param {string} key Crypto secret
|
||||
* @return {string} URL-safe and Base64 encoded digest
|
||||
*/
|
||||
base64Hmac: function (salt, value, key) {
|
||||
// Validate
|
||||
if (typeof salt !== 'string') return;
|
||||
if (typeof value !== 'string') return;
|
||||
if (typeof key !== 'string') return;
|
||||
|
||||
// Get salted HMAC digest and Base64 encode
|
||||
var saltedHmac = signer.getSaltedHmac(salt + 'signer', value, key);
|
||||
return util.b64Encode(saltedHmac.digest('binary'));
|
||||
}
|
||||
};
|
70
lib/util.js
Normal file
70
lib/util.js
Normal file
|
@ -0,0 +1,70 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* Port of Python's base64.urlsafe_b64encode: Returns a base64-encoded
|
||||
* representation of s, made URL-safe by replacing + and / with - and _
|
||||
* respectively. Additionally all trailing =s are stripped from the
|
||||
* resulting value to mirror django.core.signing.b64_encode.
|
||||
* @param {string} s Input string.
|
||||
* @return {string} URL-safe encoded string.
|
||||
*/
|
||||
b64Encode: function (s) {
|
||||
// Validate
|
||||
if (typeof s !== 'string') return;
|
||||
|
||||
// Convert from binary to Base64
|
||||
var b64String = Buffer(s, 'binary').toString('base64');
|
||||
|
||||
// Replace special characters with URL-safe alternates
|
||||
return b64String.replace(
|
||||
/[+/]/g,
|
||||
function (c) {
|
||||
return {'+': '-', '/': '_'}[c];
|
||||
}
|
||||
).replace(/=+$/, '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Port of Python's base64.urlsafe_b64decode: Returns a base64-decoded
|
||||
* representation of s, made URL-safe by replacing + and / with - and _
|
||||
* respectively. Handles removal of trailing =s which are stripped from
|
||||
* encoded values to mirror django.core.signing.b64_decode.
|
||||
* @param {string} s Base64 encoded string.
|
||||
* @return {string} Decoded string.
|
||||
*/
|
||||
b64Decode: function (s) {
|
||||
// Validate
|
||||
if (typeof s !== 'string') return;
|
||||
|
||||
// Trim leading "dot" character
|
||||
if (s[0] === '.') s = s.substring(1);
|
||||
|
||||
// Replace encoded special characters
|
||||
s = s.replace(
|
||||
/[-_]/g,
|
||||
function (c) {
|
||||
return {'-': '+', '_': '/'}[c];
|
||||
}
|
||||
);
|
||||
|
||||
// Convert from Base64 to binary
|
||||
return new Buffer(s, 'base64').toString('binary');
|
||||
},
|
||||
|
||||
/**
|
||||
* Creates an MD5 hash from the specified input string.
|
||||
* @param {string} s Input string.
|
||||
* @return {string} Hash.
|
||||
*/
|
||||
md5: function (s) {
|
||||
// Validate
|
||||
if (typeof s !== 'string') return;
|
||||
|
||||
// Create hash and return hex digest
|
||||
return crypto
|
||||
.createHash('md5')
|
||||
.update(s)
|
||||
.digest('hex');
|
||||
}
|
||||
};
|
20
package.json
20
package.json
|
@ -1,16 +1,28 @@
|
|||
{
|
||||
"name": "<name>",
|
||||
"name": "scratch-auth",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"author": "Massachusetts Institute of Technology",
|
||||
"license": "BSD-3-Clause",
|
||||
"homepage": "https://github.com/LLK/<name>#readme",
|
||||
"homepage": "https://github.com/LLK/scratch-auth#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/LLK/<name>.git"
|
||||
"url": "git+ssh://git@github.com/LLK/scratch-auth.git"
|
||||
},
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "npm run lint && npm run tap",
|
||||
"lint": "eslint .",
|
||||
"tap": "tap test/{unit,integration}/*.js",
|
||||
"coverage": "tap test/{unit,integration}/*.js --coverage --coverage-report=lcov"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-eslint": "7.1.0",
|
||||
"eslint": "3.9.1",
|
||||
"eslint-config-scratch": "2.0.3",
|
||||
"tap": "8.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"pako": "1.0.3"
|
||||
}
|
||||
}
|
||||
|
|
5
test/.eslintrc.js
Normal file
5
test/.eslintrc.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
rules: {
|
||||
'no-undefined': [0]
|
||||
}
|
||||
};
|
27
test/fixtures/cases.json
vendored
Normal file
27
test/fixtures/cases.json
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"valid": {
|
||||
"salt": "test",
|
||||
"secret": "secret",
|
||||
"signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs",
|
||||
"unsigned": "value"
|
||||
},
|
||||
"invalidSalt": {
|
||||
"salt": "hax0r",
|
||||
"secret": "secret",
|
||||
"signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs"
|
||||
},
|
||||
"invalidSecret": {
|
||||
"salt": "test",
|
||||
"secret": "hax0r",
|
||||
"signed": "value:zyBNJHpGyml3X-RhCx0mbjLFzPs"
|
||||
},
|
||||
"invalidToken": {
|
||||
"salt": "test",
|
||||
"secret": "secret",
|
||||
"signed": "zyBNJHpGyml3X-RhCx0mbjLFzPs"
|
||||
},
|
||||
"missingToken": {
|
||||
"salt": "test",
|
||||
"secret": "secret"
|
||||
}
|
||||
}
|
25
test/integration/unpack.js
Normal file
25
test/integration/unpack.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
const test = require('tap').test;
|
||||
const Auth = require('../../index');
|
||||
|
||||
const object = {foo: 'bar'};
|
||||
const encoded = 'eyJmb28iOiJiYXIifQ==';
|
||||
|
||||
test('spec', function (t) {
|
||||
const a = new Auth();
|
||||
t.type(Auth, 'function');
|
||||
t.type(a, 'object');
|
||||
t.type(a.unpack, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('uncompressed', function (t) {
|
||||
const a = new Auth();
|
||||
t.strictDeepEqual(a.unpack(encoded), object);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('invalid', function (t) {
|
||||
const a = new Auth();
|
||||
t.strictDeepEqual(a.unpack(undefined), undefined);
|
||||
t.end();
|
||||
});
|
47
test/integration/unsign.js
Normal file
47
test/integration/unsign.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
const test = require('tap').test;
|
||||
const Auth = require('../../index');
|
||||
|
||||
const cases = require('../fixtures/cases');
|
||||
|
||||
test('spec', function (t) {
|
||||
const a = new Auth();
|
||||
t.type(Auth, 'function');
|
||||
t.type(a, 'object');
|
||||
t.type(a.unsign, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('valid', function (t) {
|
||||
const c = cases.valid;
|
||||
const a = new Auth(c.salt, c.secret);
|
||||
t.strictEqual(a.unsign(c.signed), c.unsigned);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('invalid salt', function (t) {
|
||||
const c = cases.invalidSalt;
|
||||
const a = new Auth(c.salt, c.secret);
|
||||
t.strictEqual(a.unsign(c.signed), c.unsigned);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('invalid secret', function (t) {
|
||||
const c = cases.invalidSecret;
|
||||
const a = new Auth(c.salt, c.secret);
|
||||
t.strictEqual(a.unsign(c.signed), c.unsigned);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('invalid token', function (t) {
|
||||
const c = cases.invalidToken;
|
||||
const a = new Auth(c.salt, c.secret);
|
||||
t.strictEqual(a.unsign(c.signed), c.unsigned);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('missing token', function (t) {
|
||||
const c = cases.missingToken;
|
||||
const a = new Auth(c.salt, c.secret);
|
||||
t.strictEqual(a.unsign(c.signed), c.unsigned);
|
||||
t.end();
|
||||
});
|
45
test/unit/signer.js
Normal file
45
test/unit/signer.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
const test = require('tap').test;
|
||||
const signer = require('../../lib/signer');
|
||||
|
||||
test('spec', function (t) {
|
||||
t.type(signer, 'object');
|
||||
t.type(signer.getSaltedHmacKey, 'function');
|
||||
t.type(signer.getSaltedHmac, 'function');
|
||||
t.type(signer.base64Hmac, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('getSaltedHmacKey', function (t) {
|
||||
// Valid input
|
||||
t.strictEqual(
|
||||
signer.getSaltedHmacKey('foo', 'bar'),
|
||||
'\x88C×ù$\x16!\x1Déë¹cÿLâ\x81%\x93(x'
|
||||
);
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(signer.getSaltedHmacKey('foo', undefined), undefined);
|
||||
t.strictEqual(signer.getSaltedHmacKey(undefined, 'bar'), undefined);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('getSaltedHmac', function (t) {
|
||||
// Valid input
|
||||
t.type(signer.getSaltedHmac('foo', 'bar', 'baz'), 'object');
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(signer.getSaltedHmac('foo', 'bar', undefined), undefined);
|
||||
t.strictEqual(signer.getSaltedHmac('foo', undefined, 'baz'), undefined);
|
||||
t.strictEqual(signer.getSaltedHmac(undefined, 'bar', 'baz'), undefined);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('base64Hmac', function (t) {
|
||||
// Valid input
|
||||
t.type(signer.base64Hmac('foo', 'bar', 'baz'), 'string');
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(signer.base64Hmac('foo', 'bar', undefined), undefined);
|
||||
t.strictEqual(signer.base64Hmac('foo', undefined, 'baz'), undefined);
|
||||
t.strictEqual(signer.base64Hmac(undefined, 'bar', 'baz'), undefined);
|
||||
t.end();
|
||||
});
|
53
test/unit/util.js
Normal file
53
test/unit/util.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const test = require('tap').test;
|
||||
const util = require('../../lib/util');
|
||||
|
||||
test('spec', function (t) {
|
||||
t.type(util, 'object');
|
||||
t.type(util.b64Encode, 'function');
|
||||
t.type(util.b64Decode, 'function');
|
||||
t.type(util.md5, 'function');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('b64Encode', function (t) {
|
||||
// Valid input
|
||||
t.strictEqual(util.b64Encode('foobar'), 'Zm9vYmFy');
|
||||
t.strictEqual(util.b64Encode('http://foo-bar'), 'aHR0cDovL2Zvby1iYXI');
|
||||
t.strictEqual(util.b64Encode('http:/`\\'), 'aHR0cDovYFw');
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(util.b64Encode(undefined), undefined);
|
||||
t.strictEqual(util.b64Encode(null), undefined);
|
||||
t.strictEqual(util.b64Encode(NaN), undefined);
|
||||
t.strictEqual(util.b64Encode(0), undefined);
|
||||
t.strictEqual(util.b64Encode({}), undefined);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('b64Decode', function (t) {
|
||||
// Valid input
|
||||
t.strictEqual(util.b64Decode('Zm9vYmFy'), 'foobar');
|
||||
t.strictEqual(util.b64Decode('aHR0cDovL2Zvby1iYXI'), 'http://foo-bar');
|
||||
t.strictEqual(util.b64Decode('.aHR0cDovYFy='), 'http:/`\\');
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(util.b64Decode(undefined), undefined);
|
||||
t.strictEqual(util.b64Decode(null), undefined);
|
||||
t.strictEqual(util.b64Decode(NaN), undefined);
|
||||
t.strictEqual(util.b64Decode(0), undefined);
|
||||
t.strictEqual(util.b64Decode({}), undefined);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('md5', function (t) {
|
||||
// Valid input
|
||||
t.strictEqual(util.md5('foobar'), '3858f62230ac3c915f300c664312c63f');
|
||||
|
||||
// Invalid input
|
||||
t.strictEqual(util.md5(undefined), undefined);
|
||||
t.strictEqual(util.md5(null), undefined);
|
||||
t.strictEqual(util.md5(NaN), undefined);
|
||||
t.strictEqual(util.md5(0), undefined);
|
||||
t.strictEqual(util.md5({}), undefined);
|
||||
t.end();
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue