Merge branch 'issuer-rewrite'

This commit is contained in:
tigerbot 2017-10-19 17:05:04 -06:00
commit f4b172af01
8 changed files with 1236 additions and 557 deletions

View File

@ -149,9 +149,10 @@ module.exports.create = function () {
process.on('unhandledRejection', function (err) { process.on('unhandledRejection', function (err) {
// this should always throw // this should always throw
// (it means somewhere we're not using bluebird by accident) // (it means somewhere we're not using bluebird by accident)
console.error('[caught] [unhandledRejection]'); console.error('[caught unhandledRejection]:', err.message || '');
console.error(Object.keys(err)); Object.keys(err).forEach(function (key) {
console.error(err); console.log('\t'+key+': '+err[key]);
});
console.error(err.stack); console.error(err.stack);
}); });
process.on('rejectionHandled', function (msg) { process.on('rejectionHandled', function (msg) {

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,21 @@
'use strict'; 'use strict';
module.exports.rejectableRequest = function rejectableRequest(req, res, promise, msg) { function rejectableRequest(req, res, promise, msg) {
return promise.error(function (err) { return promise.error(function (err) {
res.error(err); res.error(err);
}).catch(function (err) { }).catch(function (err) {
console.error('[ERROR] \'' + msg + '\''); console.error('[ERROR] \'' + msg + '\'');
console.error(err.message); // The stack contains the message as well, so no need to log the message when we log the stack
console.error(err.stack); console.error(err.stack || err.message || JSON.stringify(err));
res.error(err); res.error(err);
}); });
}; }
module.exports.rejectableRequest = rejectableRequest;
module.exports.promisableRequest = module.exports.promisableRequest =
module.exports.promiseRequest = function promiseRequest(req, res, promise, msg) { module.exports.promiseRequest = function promiseRequest(req, res, promise, msg) {
return promise.then(function (result) { promise = promise.then(function (result) {
if (result._cache) { if (result._cache) {
res.setHeader('Cache-Control', 'public, max-age=' + (result._cache / 1000)); res.setHeader('Cache-Control', 'public, max-age=' + (result._cache / 1000));
res.setHeader('Expires', new Date(Date.now() + result._cache).toUTCString()); res.setHeader('Expires', new Date(Date.now() + result._cache).toUTCString());
@ -26,13 +27,7 @@ module.exports.promiseRequest = function promiseRequest(req, res, promise, msg)
result = result._value; result = result._value;
} }
res.send(result); res.send(result);
}).error(function (err) {
res.error(err);
}).catch(function (err) {
console.error('[ERROR] \'' + msg + '\'');
console.error(err.message);
console.error(err.stack);
res.error(err);
}); });
return rejectableRequest(req, res, promise, msg);
}; };

View File

@ -1,6 +1,6 @@
'use strict'; 'use strict';
module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi) { module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi, errorIfAssets) {
var PromiseA = require('bluebird'); var PromiseA = require('bluebird');
var path = require('path'); var path = require('path');
var fs = PromiseA.promisifyAll(require('fs')); var fs = PromiseA.promisifyAll(require('fs'));
@ -293,10 +293,27 @@ module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi
// TODO handle assets.example.com/sub/assets/com.example.xyz/ // TODO handle assets.example.com/sub/assets/com.example.xyz/
app.use('/api', require('connect-send-error').error()); app.use('/api', require('connect-send-error').error());
app.use('/assets', require('connect-send-error').error());
app.use('/', function (req, res, next) { app.use('/', function (req, res, next) {
// If this doesn't look like an API we can move along // If this doesn't look like an API or assets we can move along
if (!/\/api(\/|$)/.test(req.url)) {
// /^api\./.test(req.hostname) && /*
console.log('.');
console.log('[main.js] req.url, req.hostname');
console.log(req.url);
console.log(req.hostname);
console.log('.');
*/
if (!/\/(api|assets)(\/|$)/.test(req.url)) {
//console.log('[main.js] api|assets');
next();
return;
}
// keep https://assets.example.com/assets but skip https://example.com/assets
if (/\/assets(\/|$)/.test(req.url) && !/(^|\.)(api|assets)(\.)/.test(req.hostname) && !/^[0-9\.]+$/.test(req.hostname)) {
//console.log('[main.js] skip');
next(); next();
return; return;
} }
@ -325,6 +342,7 @@ module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi
return; return;
}); });
app.use('/', errorIfApi); app.use('/', errorIfApi);
app.use('/', errorIfAssets);
app.use('/', serveStatic); app.use('/', serveStatic);
app.use('/', serveApps); app.use('/', serveApps);

306
lib/oauth3.js Normal file
View File

@ -0,0 +1,306 @@
'use strict';
var PromiseA = require('bluebird');
function generateRescope(req, Models, decoded, fullPpid, ppid) {
return function (/*sub*/) {
// TODO: this function is supposed to convert PPIDs of different parties to some account
// ID that allows application to keep track of permisions and what-not.
console.log('[rescope] Attempting ', fullPpid);
return Models.IssuerOauth3OrgGrants.find({ azpSub: fullPpid }).then(function (results) {
if (results[0]) {
console.log('[rescope] lukcy duck: got it on the 1st try');
return PromiseA.resolve(results);
}
// XXX BUG XXX
// should be able to distinguish between own ids and 3rd party via @whatever.com
return Models.IssuerOauth3OrgGrants.find({ azpSub: ppid });
}).then(function (results) {
var result = results[0];
if (!result || !result.sub || !decoded.iss) {
// XXX BUG XXX TODO swap this external ppid for an internal (and ask user to link with existing profile)
//req.oauth3.accountIdx = fullPpid;
throw new Error("internal / external ID swapping not yet implemented. TODO: "
+ "No profile found with that credential. Would you like to create a new profile or link to an existing profile?");
}
// XXX BUG XXX need to pass own url in to use as issuer for own tokens
req.oauth3.accountIdx = result.sub + '@' + decoded.iss;
console.log('[rescope] result:');
console.log(results);
console.log(req.oauth3.accountIdx);
return PromiseA.resolve(req.oauth3.accountIdx);
});
};
}
function extractAccessToken(req) {
var token = null;
var parts;
var scheme;
var credentials;
if (req.headers && req.headers.authorization) {
// Works for all of Authorization: Bearer {{ token }}, Token {{ token }}, JWT {{ token }}
parts = req.headers.authorization.split(' ');
if (parts.length !== 2) {
return PromiseA.reject(new Error("malformed Authorization header"));
}
scheme = parts[0];
credentials = parts[1];
if (-1 !== ['token', 'bearer'].indexOf(scheme.toLowerCase())) {
token = credentials;
}
}
if (req.body && req.body.access_token) {
if (token) { PromiseA.reject(new Error("token exists in header and body")); }
token = req.body.access_token;
}
// TODO disallow query with req.method === 'GET'
// NOTE: the case of DDNS on routers requires a GET and access_token
// (cookies should be used for protected static assets)
if (req.query && req.query.access_token) {
if (token) { PromiseA.reject(new Error("token already exists in either header or body and also in query")); }
token = req.query.access_token;
}
/*
err = new Error(challenge());
err.code = 'E_BEARER_REALM';
if (!token) { return PromiseA.reject(err); }
*/
return PromiseA.resolve(token);
}
function verifyToken(token) {
var jwt = require('jsonwebtoken');
var decoded;
if (!token) {
return PromiseA.reject({
message: 'no token provided'
, code: 'E_NO_TOKEN'
, url: 'https://oauth3.org/docs/errors#E_NO_TOKEN'
});
}
try {
decoded = jwt.decode(token, {complete: true});
} catch (e) {}
if (!decoded) {
return PromiseA.reject({
message: 'provided token not a JSON Web Token'
, code: 'E_NOT_JWT'
, url: 'https://oauth3.org/docs/errors#E_NOT_JWT'
});
}
var sub = decoded.payload.sub || decoded.payload.ppid || decoded.payload.appScopedId;
if (!sub) {
return PromiseA.reject({
message: 'token missing sub'
, code: 'E_MISSING_SUB'
, url: 'https://oauth3.org/docs/errors#E_MISSING_SUB'
});
}
var kid = decoded.header.kid || decoded.payload.kid;
if (!kid) {
return PromiseA.reject({
message: 'token missing kid'
, code: 'E_MISSING_KID'
, url: 'https://oauth3.org/docs/errors#E_MISSING_KID'
});
}
if (!decoded.payload.iss) {
return PromiseA.reject({
message: 'token missing iss'
, code: 'E_MISSING_ISS'
, url: 'https://oauth3.org/docs/errors#E_MISSING_ISS'
});
}
var OAUTH3 = require('oauth3.js');
OAUTH3._hooks = require('oauth3.js/oauth3.node.storage.js');
return OAUTH3.discover(decoded.payload.iss).then(function (directives) {
var args = (directives || {}).retrieve_jwk;
if (typeof args === 'string') {
args = { url: args, method: 'GET' };
}
if (typeof (args || {}).url !== 'string') {
return PromiseA.reject({
message: 'token issuer does not support retrieving JWKs'
, code: 'E_INVALID_ISS'
, url: 'https://oauth3.org/docs/errors#E_INVALID_ISS'
});
}
var params = {
sub: sub
, kid: kid
};
var url = args.url;
var body;
Object.keys(params).forEach(function (key) {
if (url.indexOf(':'+key) !== -1) {
url = url.replace(':'+key, params[key]);
delete params[key];
}
});
if (Object.keys(params).length > 0) {
if ('GET' === (args.method || 'GET').toUpperCase()) {
url += '?' + OAUTH3.query.stringify(params);
} else {
body = params;
}
}
return OAUTH3.request({
url: OAUTH3.url.resolve(directives.api, url)
, method: args.method
, data: body
}).catch(function (err) {
return PromiseA.reject({
message: 'failed to retrieve public key from token issuer'
, code: 'E_NO_PUB_KEY'
, url: 'https://oauth3.org/docs/errors#E_NO_PUB_KEY'
, subErr: err.toString()
});
});
}, function (err) {
return PromiseA.reject({
message: 'token issuer is not a valid OAuth3 provider'
, code: 'E_INVALID_ISS'
, url: 'https://oauth3.org/docs/errors#E_INVALID_ISS'
, subErr: err.toString()
});
}).then(function (res) {
if (res.data.error) {
return PromiseA.reject(res.data.error);
}
var opts = {};
if (Array.isArray(res.data.alg)) {
opts.algorithms = res.data.alg;
} else if (typeof res.data.alg === 'string') {
opts.algorithms = [res.data.alg];
}
try {
return jwt.verify(token, require('jwk-to-pem')(res.data), opts);
} catch (err) {
return PromiseA.reject({
message: 'token verification failed'
, code: 'E_INVALID_TOKEN'
, url: 'https://oauth3.org/docs/errors#E_INVALID_TOKEN'
, subErr: err.toString()
});
}
});
}
function deepFreeze(obj) {
Object.keys(obj).forEach(function (key) {
if (obj[key] && typeof obj[key] === 'object') {
deepFreeze(obj[key]);
}
});
Object.freeze(obj);
}
function cookieOauth3(Models, req, res, next) {
req.oauth3 = {};
var token = req.cookies.jwt;
req.oauth3.encodedToken = token;
req.oauth3.verifyAsync = function (jwt) {
return verifyToken(jwt || token);
};
return verifyToken(token).then(function (decoded) {
req.oauth3.token = decoded;
if (!decoded) {
return null;
}
var ppid = decoded.sub || decoded.ppid || decoded.appScopedId;
req.oauth3.ppid = ppid;
req.oauth3.accountIdx = ppid+'@'+decoded.iss;
var hash = require('crypto').createHash('sha256').update(req.oauth3.accountIdx).digest('base64');
hash = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+/g, '');
req.oauth3.accountHash = hash;
req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid);
}).then(function () {
deepFreeze(req.oauth3);
//Object.defineProperty(req, 'oauth3', {configurable: false, writable: false});
next();
}, function (err) {
if ('E_NO_TOKEN' === err.code) {
next();
return;
}
console.error('[walnut] cookie lib/oauth3 error:');
console.error(err);
res.send(err);
});
}
function attachOauth3(Models, req, res, next) {
req.oauth3 = {};
extractAccessToken(req).then(function (token) {
req.oauth3.encodedToken = token;
req.oauth3.verifyAsync = function (jwt) {
return verifyToken(jwt || token);
};
if (!token) {
return null;
}
return verifyToken(token);
}).then(function (decoded) {
req.oauth3.token = decoded;
if (!decoded) {
return null;
}
var ppid = decoded.sub || decoded.ppid || decoded.appScopedId;
var fullPpid = ppid+'@'+decoded.iss;
req.oauth3.ppid = ppid;
// TODO we can anonymize the relationship between our user as the other service's user
// in our own database by hashing the remote service's ppid and using that as the lookup
var hash = require('crypto').createHash('sha256').update(fullPpid).digest('base64');
hash = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+/g, '');
req.oauth3.accountHash = hash;
req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid);
console.log('############### assigned req.oauth3:');
console.log(req.oauth3);
}).then(function () {
//deepFreeze(req.oauth3);
//Object.defineProperty(req, 'oauth3', {configurable: false, writable: false});
next();
}, function (err) {
console.error('[walnut] JWT lib/oauth3 error:');
console.error(err);
res.send(err);
});
}
module.exports.attachOauth3 = attachOauth3;
module.exports.cookieOauth3 = cookieOauth3;
module.exports.verifyToken = verifyToken;

View File

@ -55,19 +55,7 @@ function getApi(conf, pkgConf, pkgDeps, packagedApi) {
packagedApi._api = require('express-lazy')(); packagedApi._api = require('express-lazy')();
packagedApi._api_app = myApp; packagedApi._api_app = myApp;
//require('./oauth3-auth').inject(conf, packagedApi._api, pkgConf, pkgDeps); packagedApi._api.use('/', require('./oauth3').attachOauth3);
pkgDeps.getOauth3Controllers =
packagedApi._getOauth3Controllers = require('oauthcommon/example-oauthmodels').create(conf).getControllers;
require('oauthcommon').inject(packagedApi._getOauth3Controllers, packagedApi._api, pkgConf, pkgDeps);
// DEBUG
//
/*
packagedApi._api.use('/', function (req, res, next) {
console.log('[DEBUG pkgApiApp]', req.method, req.hostname, req.url);
next();
});
//*/
// TODO fix backwards compat // TODO fix backwards compat

View File

@ -150,6 +150,21 @@ module.exports.create = function (webserver, xconfx, state) {
models: models models: models
// TODO don't let packages use this directly // TODO don't let packages use this directly
, Promise: PromiseA , Promise: PromiseA
, dns: PromiseA.promisifyAll(require('dns'))
, crypto: PromiseA.promisifyAll(require('crypto'))
, fs: PromiseA.promisifyAll(require('fs'))
, path: require('path')
, validate: {
isEmail: function (email) {
return /@/.test(email) && !/\s+/.test(email);
}
, email: function (email) {
if (apiDeps.validate.isEmail(email)) {
return null;
}
return new Error('invalid email address');
}
}
}; };
var apiFactories = { var apiFactories = {
memstoreFactory: { create: scopeMemstore } memstoreFactory: { create: scopeMemstore }
@ -180,7 +195,7 @@ module.exports.create = function (webserver, xconfx, state) {
function setupMain() { function setupMain() {
if (xconfx.debug) { console.log('[main] setup'); } if (xconfx.debug) { console.log('[main] setup'); }
mainApp = express(); mainApp = express();
require('./main').create(mainApp, xconfx, apiFactories, apiDeps, errorIfApi).then(function () { require('./main').create(mainApp, xconfx, apiFactories, apiDeps, errorIfApi, errorIfAssets).then(function () {
if (xconfx.debug) { console.log('[main] ready'); } if (xconfx.debug) { console.log('[main] ready'); }
// TODO process.send({}); // TODO process.send({});
}); });
@ -225,6 +240,24 @@ module.exports.create = function (webserver, xconfx, state) {
next(); next();
} }
function errorIfNotAssets(req, res, next) {
var hostname = req.hostname || req.headers.host;
if (!/^assets\.[a-z0-9\-]+/.test(hostname)) {
res.send({ error:
{ message: "['" + hostname + req.url + "'] protected asset access is restricted to proper 'asset'-prefixed lowercase subdomains."
+ " The HTTP 'Host' header must exist and must begin with 'assets.' as in 'assets.example.com'."
+ " For development you may test with assets.localhost.daplie.me (or any domain by modifying your /etc/hosts)"
, code: 'E_NOT_API'
, _hostname: hostname
}
});
return;
}
next();
}
function errorIfApi(req, res, next) { function errorIfApi(req, res, next) {
if (!/^api\./.test(req.headers.host)) { if (!/^api\./.test(req.headers.host)) {
next(); next();
@ -240,7 +273,25 @@ module.exports.create = function (webserver, xconfx, state) {
return; return;
} }
res.send({ error: { code: 'E_NO_IMPL', message: "not implemented" } }); res.send({ error: { code: 'E_NO_IMPL', message: "API not implemented" } });
}
function errorIfAssets(req, res, next) {
if (!/^assets\./.test(req.headers.host)) {
next();
return;
}
// has api. hostname prefix
// doesn't have /api url prefix
if (!/^\/assets\//.test(req.url)) {
console.log('[walnut/worker assets] req.url', req.url);
res.send({ error: { message: "missing /assets/ url prefix" } });
return;
}
res.send({ error: { code: 'E_NO_IMPL', message: "assets handler not implemented" } });
} }
app.disable('x-powered-by'); app.disable('x-powered-by');
@ -258,8 +309,11 @@ module.exports.create = function (webserver, xconfx, state) {
})); }));
app.use('/api', recase); app.use('/api', recase);
var cookieParser = require('cookie-parser'); // signing is done in JWT
app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']);
app.use('/api', errorIfNotApi); app.use('/api', errorIfNotApi);
app.use('/assets', /*errorIfNotAssets,*/ cookieParser()); // serializer { path: '/assets', httpOnly: true, sameSite: true/*, domain: assets.example.com*/ }
app.use('/', function (req, res) { app.use('/', function (req, res) {
if (!(req.encrypted || req.secure)) { if (!(req.encrypted || req.secure)) {
// did not come from https // did not come from https

View File

@ -52,6 +52,8 @@
"express": "4.x", "express": "4.x",
"express-lazy": "^1.1.1", "express-lazy": "^1.1.1",
"express-session": "^1.11.3", "express-session": "^1.11.3",
"jsonwebtoken": "^7.4.1",
"jwk-to-pem": "^1.2.6",
"mailchimp-api-v3": "^1.7.0", "mailchimp-api-v3": "^1.7.0",
"mandrill-api": "^1.0.45", "mandrill-api": "^1.0.45",
"masterquest-sqlite3": "git+https://git.daplie.com/node/masterquest-sqlite3.git", "masterquest-sqlite3": "git+https://git.daplie.com/node/masterquest-sqlite3.git",
@ -59,7 +61,7 @@
"multiparty": "^4.1.3", "multiparty": "^4.1.3",
"nodemailer": "^1.4.0", "nodemailer": "^1.4.0",
"nodemailer-mailgun-transport": "1.x", "nodemailer-mailgun-transport": "1.x",
"oauthcommon": "git+https://git.daplie.com/node/oauthcommon.git", "oauth3.js": "git+https://git.daplie.com/OAuth3/oauth3.js.git",
"request": "^2.81.0", "request": "^2.81.0",
"serve-static": "1.x", "serve-static": "1.x",
"sqlite3-cluster": "git+https://git.daplie.com/coolaj86/sqlite3-cluster.git#v2", "sqlite3-cluster": "git+https://git.daplie.com/coolaj86/sqlite3-cluster.git#v2",