From 5053963874b9123d0da4290cc2272ca6ee922fde Mon Sep 17 00:00:00 2001 From: tigerbot Date: Thu, 13 Jul 2017 18:23:42 -0600 Subject: [PATCH 01/12] implemented verification of token signed elsewhere --- lib/apis.js | 27 ++--- lib/oauth3.js | 201 +++++++++++++++++++++++++++++++++++++ lib/package-server-apis.js | 14 +-- package.json | 4 +- 4 files changed, 216 insertions(+), 30 deletions(-) create mode 100644 lib/oauth3.js diff --git a/lib/apis.js b/lib/apis.js index 5e9542d..6689ad2 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -291,16 +291,15 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { return; } - // every grant in the array must be present - if (!grants.every(function (grant) { - var scopes = grant.split(/\|/g); - return scopes.some(function (scp) { - return tokenScopes.some(function (s) { - return scp === s; - }); + // every grant in the array must be present, though some grants can be satisfied + // by multiple scopes. + var missing = grants.filter(function (grant) { + return !grant.split('|').some(function (scp) { + return tokenScopes.indexOf(scp) !== -1; }); - })) { - res.send({ error: { message: "Token does not contain valid grants: '" + grants + "'", code: "E_NO_GRANTS" } }); + }); + if (missing.length) { + res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); return; } @@ -308,11 +307,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { }; }; - var _getOauth3Controllers = pkgDeps.getOauth3Controllers = require('oauthcommon/example-oauthmodels').create( - { sqlite3Sock: xconfx.sqlite3Sock, ipcKey: xconfx.ipcKey } - ).getControllers; - //require('oauthcommon').inject(packagedApi._getOauth3Controllers, packagedApi._api, pkgConf, pkgDeps); - require('oauthcommon').inject(_getOauth3Controllers, myApp/*, pkgConf, pkgDeps*/); + myApp.use('/', require('./oauth3').attachOauth3); // TODO delete these caches when config changes var _stripe; @@ -725,8 +720,8 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // Canonical client names // example.com should use api.example.com/api for all requests // sub.example.com/api should resolve to sub.example.com - // example.com/subpath/api should resolve to example.com#subapp - // sub.example.com/subpath/api should resolve to sub.example.com#subapp + // example.com/subapp/api should resolve to example.com#subapp + // sub.example.com/subapp/api should resolve to sub.example.com#subapp var clientUrih = req.hostname.replace(/^api\./, '') + req.url.replace(/\/api\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, ''); var clientApiUri = req.hostname + req.url.replace(/\/api\/.*/, '/').replace(/\/$/, ''); // Canonical package names diff --git a/lib/oauth3.js b/lib/oauth3.js new file mode 100644 index 0000000..3dd1b16 --- /dev/null +++ b/lib/oauth3.js @@ -0,0 +1,201 @@ +'use strict'; + +var PromiseA = require('bluebird'); + +function extractAccessToken(req) { + var token; + 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; + 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 + }); + }, 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 attachOauth3(req, res, next) { + req.oauth3 = {}; + + extractAccessToken(req).then(function (token) { + if (!token) { + return null; + } + + var decoded; + try { + decoded = require('jsonwebtoken').decode(token); + } 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 ppid = decoded.sub || decoded.ppid || decoded.appScopedId; + + req.oauth3.encodedToken = token; + req.oauth3.token = decoded; + req.oauth3.ppid = ppid; + + req.oauth3.verifyAsync = function () { + return verifyToken(token); + }; + + req.oauth3.rescope = function () { + // 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. + return PromiseA.resolve(ppid); + }; + }).then(function () { + next(); + }, function (err) { + res.send(err); + }); +} + +module.exports.attachOauth3 = attachOauth3; +module.exports.verifyToken = verifyToken; diff --git a/lib/package-server-apis.js b/lib/package-server-apis.js index 945933b..96ae370 100644 --- a/lib/package-server-apis.js +++ b/lib/package-server-apis.js @@ -55,19 +55,7 @@ function getApi(conf, pkgConf, pkgDeps, packagedApi) { packagedApi._api = require('express-lazy')(); packagedApi._api_app = myApp; - //require('./oauth3-auth').inject(conf, packagedApi._api, pkgConf, pkgDeps); - 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(); - }); - //*/ + packagedApi._api.use('/', require('./oauth3').attachOauth3); // TODO fix backwards compat diff --git a/package.json b/package.json index 487aab0..ff2f51a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,8 @@ "express": "4.x", "express-lazy": "^1.1.1", "express-session": "^1.11.3", + "jsonwebtoken": "^7.4.1", + "jwk-to-pem": "^1.2.6", "mailchimp-api-v3": "^1.7.0", "mandrill-api": "^1.0.45", "masterquest-sqlite3": "git+https://git.daplie.com/node/masterquest-sqlite3.git", @@ -59,7 +61,7 @@ "multiparty": "^4.1.3", "nodemailer": "^1.4.0", "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", "serve-static": "1.x", "sqlite3-cluster": "git+https://git.daplie.com/coolaj86/sqlite3-cluster.git#v2", "stripe": "^4.22.0", From 4345725c838da581f4bf29e50d8644d74fd74819 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Mon, 24 Jul 2017 16:19:51 -0600 Subject: [PATCH 02/12] made verifyAsync available to check other tokens (like refresh tokens) --- lib/oauth3.js | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/lib/oauth3.js b/lib/oauth3.js index 3dd1b16..27d7220 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -50,6 +50,15 @@ function extractAccessToken(req) { 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) {} @@ -160,6 +169,10 @@ function attachOauth3(req, res, next) { req.oauth3 = {}; extractAccessToken(req).then(function (token) { + req.oauth3.verifyAsync = function (jwt) { + return verifyToken(jwt || token); + }; + if (!token) { return null; } @@ -181,14 +194,10 @@ function attachOauth3(req, res, next) { req.oauth3.token = decoded; req.oauth3.ppid = ppid; - req.oauth3.verifyAsync = function () { - return verifyToken(token); - }; - - req.oauth3.rescope = function () { + req.oauth3.rescope = 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. - return PromiseA.resolve(ppid); + return PromiseA.resolve(sub || ppid); }; }).then(function () { next(); From de594964b4352b7891fdd8ce4d68e4910b8f16ea Mon Sep 17 00:00:00 2001 From: tigerbot Date: Thu, 10 Aug 2017 11:09:39 -0600 Subject: [PATCH 03/12] added accountIdx and accountHash to req.oauth3 --- lib/oauth3.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/oauth3.js b/lib/oauth3.js index 27d7220..fc3bc6f 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -188,11 +188,21 @@ function attachOauth3(req, res, next) { , url: 'https://oauth3.org/docs/errors#E_NOT_JWT' }); } + if (!decoded.iss) { + return PromiseA.reject({ + message: 'token missing iss' + , code: 'E_MISSING_ISS' + , url: 'https://oauth3.org/docs/errors#E_MISSING_ISS' + }); + } var ppid = decoded.sub || decoded.ppid || decoded.appScopedId; req.oauth3.encodedToken = token; req.oauth3.token = decoded; req.oauth3.ppid = ppid; + req.oauth3.accountIdx = ppid+'@'+token.iss; + req.oauth3.accountHash = require('crypto').createHash('sha256').update(req.oauth3.accountIdx).digest('base64'); + req.oauth3.accountHash = req.oauth3.accountHash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, ''); req.oauth3.rescope = function (sub) { // TODO: this function is supposed to convert PPIDs of different parties to some account From 92d052faf017d373d0194d66ed7cf896b48f9c5a Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 11 Aug 2017 16:38:22 -0600 Subject: [PATCH 04/12] made req.oauth3 immutable after its creation --- lib/oauth3.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/oauth3.js b/lib/oauth3.js index fc3bc6f..28ee6a9 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -165,6 +165,15 @@ function verifyToken(token) { }); } +function deepFreeze(obj) { + Object.keys(obj).forEach(function (key) { + if (obj[key] && typeof obj[key] === 'object') { + deepFreeze(obj[key]); + } + }); + Object.freeze(obj); +} + function attachOauth3(req, res, next) { req.oauth3 = {}; @@ -210,6 +219,8 @@ function attachOauth3(req, res, next) { return PromiseA.resolve(sub || ppid); }; }).then(function () { + deepFreeze(req.oauth3); + Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); next(); }, function (err) { res.send(err); From fa3816390bccc9f80db2a894c8071ab99fd4220a Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 11 Aug 2017 17:00:18 -0600 Subject: [PATCH 05/12] verify all tokens that are provided --- lib/oauth3.js | 49 ++++++++++++++++++++++--------------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/lib/oauth3.js b/lib/oauth3.js index 28ee6a9..39440ab 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -3,7 +3,7 @@ var PromiseA = require('bluebird'); function extractAccessToken(req) { - var token; + var token = null; var parts; var scheme; var credentials; @@ -133,6 +133,13 @@ function verifyToken(token) { 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({ @@ -178,6 +185,7 @@ function attachOauth3(req, res, next) { req.oauth3 = {}; extractAccessToken(req).then(function (token) { + req.oauth3.encodedToken = token; req.oauth3.verifyAsync = function (jwt) { return verifyToken(jwt || token); }; @@ -185,38 +193,25 @@ function attachOauth3(req, res, next) { if (!token) { return null; } - - var decoded; - try { - decoded = require('jsonwebtoken').decode(token); - } 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' - }); - } - if (!decoded.iss) { - return PromiseA.reject({ - message: 'token missing iss' - , code: 'E_MISSING_ISS' - , url: 'https://oauth3.org/docs/errors#E_MISSING_ISS' - }); - } - var ppid = decoded.sub || decoded.ppid || decoded.appScopedId; - - req.oauth3.encodedToken = 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+'@'+token.iss; - req.oauth3.accountHash = require('crypto').createHash('sha256').update(req.oauth3.accountIdx).digest('base64'); - req.oauth3.accountHash = req.oauth3.accountHash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+/g, ''); + 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 = 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. - return PromiseA.resolve(sub || ppid); + return PromiseA.resolve(sub || hash); }; }).then(function () { deepFreeze(req.oauth3); From dae941323bb3c32edd546e3a84823667d584b741 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 11 Aug 2017 18:13:48 -0600 Subject: [PATCH 06/12] added another place in token to find grants in tokens --- lib/apis.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/apis.js b/lib/apis.js index 8eead30..c426c88 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -280,12 +280,13 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); return; } - if ('string' !== typeof req.oauth3.token.scp) { - res.send({ error: { message: "Token must contain a grants string in 'scp'", code: "E_NO_GRANTS" } }); + var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; + if ('string' !== typeof scope) { + res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); return; } - tokenScopes = req.oauth3.token.scp.split(/[,\s]+/mg); + tokenScopes = scope.split(/[,\s]+/mg); if (-1 !== tokenScopes.indexOf('*')) { // has full account access next(); From babfb6b38beac1d284cfae2f064c5fd8e196a151 Mon Sep 17 00:00:00 2001 From: aj Date: Wed, 16 Aug 2017 19:47:51 +0000 Subject: [PATCH 07/12] logging --- lib/apis.js | 12 ++++++++++-- lib/oauth3.js | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lib/apis.js b/lib/apis.js index c426c88..4068e0b 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -257,7 +257,6 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // let's go with this one for now and the api can choose to scope or not to scope pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId); - console.log('DEBUG pkgPath', pkgPath); myApp = express(); myApp.handlePromise = promisableRequest; myApp.handleRejection = rejectableRequest; @@ -319,7 +318,9 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { var _twilio; var _get_response; myApp.use('/', function preHandler(req, res, next) { + //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); } return getSiteConfig(clientUrih).then(function (siteConfig) { + //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); } Object.defineProperty(req, 'getSiteMailer', { enumerable: true , configurable: false @@ -722,15 +723,18 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { etcpath: xconfx.etcpath }/*pkgConf*/, pkgDeps/*pkgDeps*/, myApp/*myApp*/)).then(function (handler) { + //if (xconfx.debug) { console.log('[api.js] got handler'); } myApp.use('/', function postHandler(req, res, next) { req.url = req._walnutOriginalUrl; next(); }); localCache.pkgs[pkgId] = { pkgId: pkgId, pkg: pkg, handler: handler || myApp, createdAt: Date.now() }; + pkgLinks.forEach(function (pkgLink) { localCache.pkgs[pkgLink] = localCache.pkgs[pkgId]; }); + return localCache.pkgs[pkgId]; }); }); @@ -773,7 +777,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { return function (req, res, next) { cors(req, res, function () { - if (xconfx.debug) { console.log('[api.js] post cors'); } + //if (xconfx.debug) { console.log('[api.js] after cors'); } // Canonical client names // example.com should use api.example.com/api for all requests @@ -835,6 +839,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // TODO cache permission (although the FS is already cached, NBD) var promise = isThisClientAllowedToUseThisPkg(xconfx, clientUrih, pkgId).then(function (yes) { + //if (xconfx.debug) { console.log('[api.js] azp is allowed?', yes); } if (!yes) { notConfigured(req, res); return null; @@ -852,14 +857,17 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { if (!localCache.rests[pkgId]) { //return doesThisPkgExist + //if (xconfx.debug) { console.log('[api.js] before rest handler'); } return loadRestHandler(xconfx, clientUrih, pkgId).then(function (myHandler) { if (!myHandler) { + //if (xconfx.debug) { console.log('[api.js] not configured'); } notConfigured(req, res); return; } localCache.rests[pkgId] = { handler: myHandler.handler, createdAt: now }; if (!hasBeenHandled) { + //if (xconfx.debug) { console.log('[api.js] not configured'); } myHandler.handler(req, res, next); } }); diff --git a/lib/oauth3.js b/lib/oauth3.js index 39440ab..fc8ea59 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -218,6 +218,8 @@ function attachOauth3(req, res, next) { Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); next(); }, function (err) { + console.error('[walnut] lib/oauth3 error:'); + console.error(err); res.send(err); }); } From a429e489776342cc0d360578fd2a7d1e74be8327 Mon Sep 17 00:00:00 2001 From: aj Date: Wed, 30 Aug 2017 17:47:31 +0000 Subject: [PATCH 08/12] enable assets subdomain with cookies --- lib/apis.js | 1265 +++++++++++++++++++++++++++++-------------------- lib/main.js | 26 +- lib/oauth3.js | 49 +- lib/worker.js | 47 +- 4 files changed, 864 insertions(+), 523 deletions(-) diff --git a/lib/apis.js b/lib/apis.js index 4068e0b..f576953 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -8,7 +8,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { var express = require('express-lazy'); var fs = PromiseA.promisifyAll(require('fs')); var path = require('path'); - var localCache = { rests: {}, pkgs: {} }; + var localCache = { rests: {}, pkgs: {}, assets: {} }; var promisableRequest = require('./common').promisableRequest; var rejectableRequest = require('./common').rejectableRequest; var crypto = require('crypto'); @@ -32,7 +32,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { } */ - function isThisClientAllowedToUseThisPkg(myConf, clientUrih, pkgId) { + function isThisClientAllowedToUseThisPkg(req, myConf, clientUrih, pkgId) { var appApiGrantsPath = path.join(myConf.appApiGrantsPath, clientUrih); return fs.readFileAsync(appApiGrantsPath, 'utf8').then(function (text) { @@ -51,12 +51,23 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { return true; } - if (clientUrih === ('api.' + xconfx.setupDomain) && 'org.oauth3.consumer' === pkgId) { + console.log('#################################################'); + console.log('assets.' + xconfx.setupDomain); + console.log('assets.' + clientUrih); + console.log(req.clientAssetsUri); + console.log(pkgId); + + if (req.clientAssetsUri === ('assets.' + clientUrih) && -1 !== [ 'session', 'session@oauth3.org', 'azp@oauth3.org', 'issuer@oauth3.org' ].indexOf(pkgId)) { // fallthrough return true; - } else { - return null; } + + if (clientUrih === ('api.' + xconfx.setupDomain) && -1 !== ['org.oauth3.consumer', 'azp@oauth3.org', 'oauth3.org'].indexOf(pkgId)) { + // fallthrough + return true; + } + + return null; }); } @@ -211,10 +222,698 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { rejectableRequest(req, res, promise, "[walnut@daplie.com] required account (not /public)"); } - function loadRestHelper(myConf, clientUrih, pkgId) { - var pkgPath = path.join(myConf.restPath, pkgId); + function loadRestHelperApi(myConf, clientUrih, pkg, pkgId, pkgPath) { var pkgLinks = []; pkgLinks.push(pkgId); + var pkgRestApi; + var pkgDeps = {}; + var myApp; + var pkgPathApi; + + pkgPathApi = pkgPath; + if (pkg.walnut) { + pkgPathApi = path.join(pkgPath, pkg.walnut); + } + pkgRestApi = require(pkgPathApi); + + Object.keys(apiDeps).forEach(function (key) { + pkgDeps[key] = apiDeps[key]; + }); + Object.keys(apiFactories).forEach(function (key) { + pkgDeps[key] = apiFactories[key]; + }); + + // TODO pull db stuff from package.json somehow and pass allowed data models as deps + // + // how can we tell which of these would be correct? + // deps.memstore = apiFactories.memstoreFactory.create(pkgId); + // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId); + // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId); + + // let's go with this one for now and the api can choose to scope or not to scope + pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId); + + myApp = express(); + myApp.handlePromise = promisableRequest; + myApp.handleRejection = rejectableRequest; + myApp.grantsRequired = function (grants) { + if (!Array.isArray(grants)) { + throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); + } + + if (!grants.length) { + return function (req, res, next) { + next(); + }; + } + + return function (req, res, next) { + var tokenScopes; + + if (!(req.oauth3 || req.oauth3.token)) { + // TODO some error generator for standard messages + res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); + return; + } + var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; + if ('string' !== typeof scope) { + res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); + return; + } + + tokenScopes = scope.split(/[,\s]+/mg); + if (-1 !== tokenScopes.indexOf('*')) { + // has full account access + next(); + return; + } + + // every grant in the array must be present, though some grants can be satisfied + // by multiple scopes. + var missing = grants.filter(function (grant) { + return !grant.split('|').some(function (scp) { + return tokenScopes.indexOf(scp) !== -1; + }); + }); + if (missing.length) { + res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); + return; + } + + next(); + }; + }; + + myApp.use('/', require('./oauth3').attachOauth3); + + // TODO delete these caches when config changes + var _stripe; + var _stripe_test; + var _mandrill; + var _mailchimp; + var _twilio; + var _get_response; + myApp.use('/', function preHandler(req, res, next) { + //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); } + return getSiteConfig(clientUrih).then(function (siteConfig) { + //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); } + Object.defineProperty(req, 'getSiteMailer', { + enumerable: true + , configurable: false + , writable: false + , value: function getSiteMailerProp() { + var nodemailer = require('nodemailer'); + var transport = require('nodemailer-mailgun-transport'); + //var mailconf = require('../../../com.daplie.mailer/config.mailgun'); + var mailconf = siteConfig['mailgun.org']; + var mailer = PromiseA.promisifyAll(nodemailer.createTransport(transport(mailconf))); + + return mailer; + } + }); + + Object.defineProperty(req, 'getSiteConfig', { + enumerable: true + , configurable: false + , writable: false + , value: function getSiteConfigProp(section) { + // deprecated + if ('com.daplie.tel' === section) { + section = 'tel@daplie.com'; + } + return PromiseA.resolve((siteConfig || {})[section]); + } + }); + + Object.defineProperty(req, 'getSitePackageConfig', { + enumerable: true + , configurable: false + , writable: false + , value: function getSitePackageConfigProp() { + return getSitePackageConfig(clientUrih, pkgId); + } + }); + + Object.defineProperty(req, 'getSiteStore', { + enumerable: true + , configurable: false + , writable: false + , value: function getSiteStoreProp() { + var restPath = path.join(myConf.restPath, pkgId); + var apiPath = path.join(myConf.apiPath, pkgId); + var dir; + + // TODO usage package.json as a falback if the standard location is not used + try { + dir = require(path.join(apiPath, 'models.js')); + } catch(e) { + dir = require(path.join(restPath, 'models.js')); + } + + return getSiteStore(clientUrih, pkgId, dir); + } + }); + + /* + Object.defineProperty(req, 'getSitePayments', { + enumerable: true + , configurable: false + , writable: false + , value: function getSitePaymentsProp() { + } + }); + */ + // TODO allow third-party clients stripe ids destination + // https://stripe.com/docs/connect/payments-fees + Object.defineProperty(req, 'Stripe', { + enumerable: true + , configurable: false + , get: function () { + _stripe = _stripe || require('stripe')(siteConfig['stripe.com'].live.secret); + return _stripe; + } + }); + + Object.defineProperty(req, 'StripeTest', { + enumerable: true + , configurable: false + , get: function () { + _stripe_test = _stripe_test || require('stripe')(siteConfig['stripe.com'].test.secret); + return _stripe_test; + } + }); + + Object.defineProperty(req, 'Mandrill', { + enumerable: true + , configurable: false + , get: function () { + if (!_mandrill) { + var Mandrill = require('mandrill-api/mandrill'); + _mandrill = new Mandrill.Mandrill(siteConfig['mandrill.com'].apiKey); + _mandrill.messages.sendTemplateAsync = function (opts) { + return new PromiseA(function (resolve, reject) { + _mandrill.messages.sendTemplate(opts, resolve, reject); + }); + }; + } + return _mandrill; + } + }); + + Object.defineProperty(req, 'Mailchimp', { + enumerable: true + , configurable: false + , get: function () { + var Mailchimp = require('mailchimp-api-v3'); + _mailchimp = _mailchimp || new Mailchimp(siteConfig['mailchimp.com'].apiKey); + return _mailchimp; + } + }); + + Object.defineProperty(req, 'GetResponse', { + enumerable: true + , configurable: false + , get: function () { + if (_get_response) { + return _get_response; + } + _get_response = { + saveSubscriber: function (email, opts) { + var config = siteConfig['getresponse@daplie.com']; + var customFields = []; + Object.keys(config.customFields).forEach(function (name) { + if (typeof opts[name] !== 'undefined') { + customFields.push({ + customFieldId: config.customFields[name] + , value: [ String(opts[name]) ] + }); + } + }); + + return request({ + method: 'POST' + , url: 'https://api.getresponse.com/v3/contacts' + , headers: { 'X-Auth-Token': 'api-key ' + config.apiKey } + , json: true + , body: { + name: opts.name + , email: email + , ipAddress: opts.ipAddress + , campaign: { campaignId: config.campaignId } + , customFieldValues: customFields + } + }).then(function (resp) { + if (resp.statusCode === 202) { + return; + } + return PromiseA.reject(resp.body.message); + }); + } + }; + + return _get_response; + } + }); + + var Twilio = require('twilio'); + function twilioTel(/*opts*/) { + if (_twilio) { + return apiDeps.Promise.resolve(_twilio); + } + + _twilio = new Twilio.RestClient( + siteConfig['twilio.com'].live.id + , siteConfig['twilio.com'].live.auth + ); + return apiDeps.Promise.resolve(_twilio); + } + + // TODO shared memory db + var mailgunTokens = {}; + function validateMailgun(apiKey, timestamp, token, signature) { + // https://gist.github.com/coolaj86/81a3b61353d2f0a2552c + // (realized later) + // HAHA HAHA HAHAHAHAHA this is my own gist... so much more polite attribution + var scmp = require('scmp') + , mailgunExpirey = 15 * 60 * 1000 + , mailgunHashType = 'sha256' + , mailgunSignatureEncoding = 'hex' + ; + var actual + , adjustedTimestamp = parseInt(timestamp, 10) * 1000 + , fresh = (Math.abs(Date.now() - adjustedTimestamp) < mailgunExpirey) + ; + + if (!fresh) { + console.error('[mailgun] Stale Timestamp: this may be an attack'); + console.error('[mailgun] However, this is most likely your fault\n'); + console.error('[mailgun] run `ntpdate ntp.ubuntu.com` and check your system clock\n'); + console.error('[mailgun] System Time: ' + new Date().toString()); + console.error('[mailgun] Mailgun Time: ' + new Date(adjustedTimestamp).toString(), timestamp); + console.error('[mailgun] Delta: ' + (Date.now() - adjustedTimestamp)); + return false; + } + + if (mailgunTokens[token]) { + console.error('[mailgun] Replay Attack'); + return false; + } + + mailgunTokens[token] = true; + + setTimeout(function () { + delete mailgunTokens[token]; + }, mailgunExpirey + (5 * 1000)); + + actual = crypto.createHmac(mailgunHashType, apiKey) + .update(new Buffer(timestamp + token, 'utf8')) + .digest(mailgunSignatureEncoding) + ; + return scmp(signature, actual); + } + + function mailgunMail(/*opts*/) { + return apiDeps.Promise.resolve(req.getSiteMailer()); + } + function getResponseList() { + return apiDeps.Promise.resolve(req.GetResponse); + } + + // Twilio Parameters are often 26 long + var bodyParserTwilio = require('body-parser').urlencoded({ limit: '4kb', parameterLimit: 100, extended: false }); + // Mailgun has something like 50 parameters + var bodyParserMailgun = require('body-parser').urlencoded({ limit: '1024kb', parameterLimit: 500, extended: false }); + function bodyMultiParserMailgun (req, res, next) { + var multiparty = require('multiparty'); + var form = new multiparty.Form(); + + form.parse(req, function (err, fields/*, files*/) { + if (err) { + console.error('Error'); + console.error(err); + res.end("Couldn't parse form"); + return; + } + + var body; + req.body = req.body || {}; + Object.keys(fields).forEach(function (key) { + // TODO what if there were two of something? + // (even though there won't be) + req.body[key] = fields[key][0]; + }); + body = req.body; + + next(); + }); + } + + function daplieTel() { + return twilioTel().then(function (twilio) { + function sms(opts) { + // opts = { to, from, body } + return new apiDeps.Promise(function (resolve, reject) { + twilio.sendSms(opts, function (err, resp) { + if (err) { + reject(err); + return; + } + + resolve(resp); + }); + }); + } + + return { + sms: sms + , mms: function () { throw new Error('MMS Not Implemented'); } + }; + }); + } + + var caps = { + // + // Capabilities for APIs + // + 'email@daplie.com': mailgunMail // whichever mailer + , 'mailer@daplie.com': mailgunMail // whichever mailer + , 'mailgun@daplie.com': mailgunMail // specifically mailgun + , 'tel@daplie.com': daplieTel // whichever telephony service + , 'twilio@daplie.com': twilioTel // specifically twilio + , 'com.daplie.tel.twilio': twilioTel // deprecated alias + + , 'getresponse@daplie.com': getResponseList + // + // Webhook Parsers + // + //, 'mailgun.urlencoded@daplie.com': function (req, res, next) { ... } + , 'mailgun.parsers@daplie.com': function (req, res, next) { + var chunks = []; + + req.on('data', function (chunk) { + chunks.push(chunk); + }); + req.on('end', function () { + }); + + function verify() { + var body = req.body; + var mailconf = siteConfig['mailgun.org']; + + if (!body.timestamp) { + console.log('mailgun parser req.headers'); + console.log(req.headers); + chunks.forEach(function (datum) { + console.log('Length:', datum.length); + //console.log(datum.toString('utf8')); + }); + console.log('weird body'); + console.log(body); + } + + if (!validateMailgun(mailconf.apiKey, body.timestamp, body.token, body.signature)) { + console.error('Request came, but not from Mailgun'); + console.error(req.url); + console.error(req.headers); + res.send({ error: { message: 'Invalid signature. Are you even Mailgun?' } }); + return; + } + + next(); + } + + if (/urlencoded/.test(req.headers['content-type'])) { + console.log('urlencoded'); + bodyParserMailgun(req, res, verify); + } + else if (/multipart/.test(req.headers['content-type'])) { + console.log('multipart'); + bodyMultiParserMailgun(req, res, verify); + } + else { + console.log('no parser'); + next(); + } + } + , 'twilio.urlencoded@daplie.com': function (req, res, next) { + // TODO null for res and Promise instead of next? + return bodyParserTwilio(req, res, function () { + var signature = req.headers['x-twilio-signature']; + var auth = siteConfig['twilio.com'].live.auth; + var fullUrl = 'https://' + req.headers.host + req._walnutOriginalUrl; + var validSig = Twilio.validateRequest(auth, signature, fullUrl, req.body); + /* + console.log('Twilio Signature Check'); + console.log('auth', auth); + console.log('sig', signature); + console.log('fullUrl', fullUrl); + console.log(req.body); + console.log('valid', validSig); + */ + if (!validSig) { + res.statusCode = 401; + res.setHeader('Content-Type', 'text/xml'); + res.end('Invalid signature. Are you even Twilio?'); + return; + } + // TODO session via db req.body.CallId req.body.smsId + next(); + }); + } + }; + req.getSiteCapability = function (capname, opts, b, c) { + if (caps[capname]) { + return caps[capname](opts, b, c); + } + if (siteConfig[capname]) { + var service = siteConfig[capname].service || siteConfig[capname]; + if (caps[service]) { + return caps[service](opts, b, c); + } + } + return apiDeps.Promise.reject( + new Error("['" + req.clientApiUri + '/' + pkgId + "'] " + + "capability '" + capname + "' not implemented") + ); + }; + + req._walnutOriginalUrl = req.url; + // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello' + req.url = req.url.replace(/\/api\//, '').replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/'); + next(); + }); + }); + myApp.use('/public', function preHandler(req, res, next) { + // TODO authenticate or use guest user + req.isPublic = true; + next(); + }); + myApp.use('/accounts/:accountId', accountRequiredById); + myApp.use('/acl', accountRequired); + + // + // TODO handle /accounts/:accountId + // + return PromiseA.resolve(pkgRestApi.create({ + etcpath: xconfx.etcpath + }/*pkgConf*/, pkgDeps/*pkgDeps*/, myApp/*myApp*/)).then(function (handler) { + + //if (xconfx.debug) { console.log('[api.js] got handler'); } + myApp.use('/', function postHandler(req, res, next) { + req.url = req._walnutOriginalUrl; + next(); + }); + + localCache.pkgs[pkgId] = { pkgId: pkgId, pkg: pkg, handler: handler || myApp, createdAt: Date.now() }; + + pkgLinks.forEach(function (pkgLink) { + localCache.pkgs[pkgLink] = localCache.pkgs[pkgId]; + }); + + return localCache.pkgs[pkgId]; + }); + } + function loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath) { + var myApp; + var pkgDeps = {}; + var pkgRestAssets = require(path.join(pkgPath, 'assets.js')); + + Object.keys(apiDeps).forEach(function (key) { + pkgDeps[key] = apiDeps[key]; + }); + Object.keys(apiFactories).forEach(function (key) { + pkgDeps[key] = apiFactories[key]; + }); + + // TODO pull db stuff from package.json somehow and pass allowed data models as deps + // + // how can we tell which of these would be correct? + // deps.memstore = apiFactories.memstoreFactory.create(pkgId); + // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId); + // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId); + + // let's go with this one for now and the api can choose to scope or not to scope + pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId); + + myApp = express(); + myApp.handlePromise = promisableRequest; + myApp.handleRejection = rejectableRequest; + myApp.grantsRequired = function (grants) { + if (!Array.isArray(grants)) { + throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); + } + + if (!grants.length) { + return function (req, res, next) { + next(); + }; + } + + return function (req, res, next) { + var tokenScopes; + + if (!(req.oauth3 || req.oauth3.token)) { + // TODO some error generator for standard messages + res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); + return; + } + var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; + if ('string' !== typeof scope) { + res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); + return; + } + + tokenScopes = scope.split(/[,\s]+/mg); + if (-1 !== tokenScopes.indexOf('*')) { + // has full account access + next(); + return; + } + + // every grant in the array must be present, though some grants can be satisfied + // by multiple scopes. + var missing = grants.filter(function (grant) { + return !grant.split('|').some(function (scp) { + return tokenScopes.indexOf(scp) !== -1; + }); + }); + if (missing.length) { + res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); + return; + } + + next(); + }; + }; + + myApp.use('/', require('./oauth3').cookieOauth3); + myApp.use('/', function (req, res, next) { + console.log('########################################### session ###############################'); + console.log('req.url', req.url); + console.log('req.oauth3', req.oauth3); + next(); + }); + myApp.post('/assets/issuer@oauth3.org/session', require('./oauth3').attachOauth3, function (req, res) { + console.log('get the session'); + console.log(req.url); + console.log("req.cookies:"); + console.log(req.cookies); + console.log("req.oauth3:"); + console.log(req.oauth3); + res.cookie('jwt', req.oauth3.encodedToken, { domain: req.clientAssetsUri, path: '/assets', httpOnly: true }); + //req.url; + res.send({ success: true }); + }); + + // TODO delete these caches when config changes + myApp.use('/', function preHandler(req, res, next) { + //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); } + return getSiteConfig(clientUrih).then(function (siteConfig) { + //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); } + + Object.defineProperty(req, 'getSiteConfig', { + enumerable: true + , configurable: false + , writable: false + , value: function getSiteConfigProp(section) { + return PromiseA.resolve((siteConfig || {})[section]); + } + }); + + Object.defineProperty(req, 'getSitePackageConfig', { + enumerable: true + , configurable: false + , writable: false + , value: function getSitePackageConfigProp() { + return getSitePackageConfig(clientUrih, pkgId); + } + }); + + Object.defineProperty(req, 'getSiteStore', { + enumerable: true + , configurable: false + , writable: false + , value: function getSiteStoreProp() { + var restPath = path.join(myConf.restPath, pkgId); + var apiPath = path.join(myConf.apiPath, pkgId); + var dir; + + // TODO usage package.json as a falback if the standard location is not used + try { + dir = require(path.join(apiPath, 'models.js')); + } catch(e) { + dir = require(path.join(restPath, 'models.js')); + } + + return getSiteStore(clientUrih, pkgId, dir); + } + }); + + req._walnutOriginalUrl = req.url; + // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello' + req.url = req.url.replace(/\/(api|assets)\//, '').replace(/.*\/(api|assets)\//, '').replace(/([^\/]*\/+)/, '/'); + next(); + }); + }); + + myApp.use('/public', function preHandler(req, res, next) { + // TODO authenticate or use guest user + req.isPublic = true; + next(); + }); + myApp.use('/accounts/:accountId', accountRequiredById); + myApp.use('/acl', accountRequired); + + // + // TODO handle /accounts/:accountId + // + function myAppWrapper(req, res, next) { + return myApp(req, res, next); + } + Object.keys(myApp).forEach(function (key) { + myAppWrapper[key] = myApp[key]; + }); + myAppWrapper.use = function () { myApp.use.apply(myApp, arguments); }; + myAppWrapper.get = function () { myApp.get.apply(myApp, arguments); }; + myAppWrapper.post = function () { myApp.use(function (req, res, next) { next(); }); /*throw new Error("assets may not handle POST");*/ }; + myAppWrapper.put = function () { throw new Error("assets may not handle PUT"); }; + myAppWrapper.del = function () { throw new Error("assets may not handle DELETE"); }; + myAppWrapper.delete = function () { throw new Error("assets may not handle DELETE"); }; + return PromiseA.resolve(pkgRestAssets.create({ + etcpath: xconfx.etcpath + }/*pkgConf*/, pkgDeps/*pkgDeps*/, myAppWrapper)).then(function (assetsHandler) { + + //if (xconfx.debug) { console.log('[api.js] got handler'); } + myApp.use('/', function postHandler(req, res, next) { + req.url = req._walnutOriginalUrl; + next(); + }); + + return assetsHandler || myApp; + }); + } + function loadRestHelper(myConf, clientUrih, pkgId) { + var pkgPath = path.join(myConf.restPath, pkgId); // TODO allow recursion, but catch cycles return fs.lstatAsync(pkgPath).then(function (stat) { @@ -233,510 +932,13 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // TODO should not require package.json. Should work with files alone. return fs.readFileAsync(path.join(pkgPath, 'package.json'), 'utf8').then(function (text) { var pkg = JSON.parse(text); - var pkgDeps = {}; - var myApp; - if (pkg.walnut) { - pkgPath = path.join(pkgPath, pkg.walnut); - } - - Object.keys(apiDeps).forEach(function (key) { - pkgDeps[key] = apiDeps[key]; - }); - Object.keys(apiFactories).forEach(function (key) { - pkgDeps[key] = apiFactories[key]; - }); - - // TODO pull db stuff from package.json somehow and pass allowed data models as deps - // - // how can we tell which of these would be correct? - // deps.memstore = apiFactories.memstoreFactory.create(pkgId); - // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId); - // deps.memstore = apiFactories.memstoreFactory.create(req.experienceId + pkgId); - - // let's go with this one for now and the api can choose to scope or not to scope - pkgDeps.memstore = apiFactories.memstoreFactory.create(pkgId); - - myApp = express(); - myApp.handlePromise = promisableRequest; - myApp.handleRejection = rejectableRequest; - myApp.grantsRequired = function (grants) { - if (!Array.isArray(grants)) { - throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); - } - - if (!grants.length) { - return function (req, res, next) { - next(); - }; - } - - return function (req, res, next) { - var tokenScopes; - - if (!(req.oauth3 || req.oauth3.token)) { - // TODO some error generator for standard messages - res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); - return; - } - var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; - if ('string' !== typeof scope) { - res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); - return; - } - - tokenScopes = scope.split(/[,\s]+/mg); - if (-1 !== tokenScopes.indexOf('*')) { - // has full account access - next(); - return; - } - - // every grant in the array must be present, though some grants can be satisfied - // by multiple scopes. - var missing = grants.filter(function (grant) { - return !grant.split('|').some(function (scp) { - return tokenScopes.indexOf(scp) !== -1; - }); - }); - if (missing.length) { - res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); - return; - } - - next(); - }; - }; - - myApp.use('/', require('./oauth3').attachOauth3); - - // TODO delete these caches when config changes - var _stripe; - var _stripe_test; - var _mandrill; - var _mailchimp; - var _twilio; - var _get_response; - myApp.use('/', function preHandler(req, res, next) { - //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); } - return getSiteConfig(clientUrih).then(function (siteConfig) { - //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); } - Object.defineProperty(req, 'getSiteMailer', { - enumerable: true - , configurable: false - , writable: false - , value: function getSiteMailerProp() { - var nodemailer = require('nodemailer'); - var transport = require('nodemailer-mailgun-transport'); - //var mailconf = require('../../../com.daplie.mailer/config.mailgun'); - var mailconf = siteConfig['mailgun.org']; - var mailer = PromiseA.promisifyAll(nodemailer.createTransport(transport(mailconf))); - - return mailer; - } - }); - - Object.defineProperty(req, 'getSiteConfig', { - enumerable: true - , configurable: false - , writable: false - , value: function getSiteConfigProp(section) { - // deprecated - if ('com.daplie.tel' === section) { - section = 'tel@daplie.com'; - } - return PromiseA.resolve((siteConfig || {})[section]); - } - }); - - Object.defineProperty(req, 'getSitePackageConfig', { - enumerable: true - , configurable: false - , writable: false - , value: function getSitePackageConfigProp() { - return getSitePackageConfig(clientUrih, pkgId); - } - }); - - Object.defineProperty(req, 'getSiteStore', { - enumerable: true - , configurable: false - , writable: false - , value: function getSiteStoreProp() { - var restPath = path.join(myConf.restPath, pkgId); - var apiPath = path.join(myConf.apiPath, pkgId); - var dir; - - // TODO usage package.json as a falback if the standard location is not used - try { - dir = require(path.join(apiPath, 'models.js')); - } catch(e) { - dir = require(path.join(restPath, 'models.js')); - } - - return getSiteStore(clientUrih, pkgId, dir); - } - }); - - /* - Object.defineProperty(req, 'getSitePayments', { - enumerable: true - , configurable: false - , writable: false - , value: function getSitePaymentsProp() { - } - }); - */ - // TODO allow third-party clients stripe ids destination - // https://stripe.com/docs/connect/payments-fees - Object.defineProperty(req, 'Stripe', { - enumerable: true - , configurable: false - , get: function () { - _stripe = _stripe || require('stripe')(siteConfig['stripe.com'].live.secret); - return _stripe; - } - }); - - Object.defineProperty(req, 'StripeTest', { - enumerable: true - , configurable: false - , get: function () { - _stripe_test = _stripe_test || require('stripe')(siteConfig['stripe.com'].test.secret); - return _stripe_test; - } - }); - - Object.defineProperty(req, 'Mandrill', { - enumerable: true - , configurable: false - , get: function () { - if (!_mandrill) { - var Mandrill = require('mandrill-api/mandrill'); - _mandrill = new Mandrill.Mandrill(siteConfig['mandrill.com'].apiKey); - _mandrill.messages.sendTemplateAsync = function (opts) { - return new PromiseA(function (resolve, reject) { - _mandrill.messages.sendTemplate(opts, resolve, reject); - }); - }; - } - return _mandrill; - } - }); - - Object.defineProperty(req, 'Mailchimp', { - enumerable: true - , configurable: false - , get: function () { - var Mailchimp = require('mailchimp-api-v3'); - _mailchimp = _mailchimp || new Mailchimp(siteConfig['mailchimp.com'].apiKey); - return _mailchimp; - } - }); - - Object.defineProperty(req, 'GetResponse', { - enumerable: true - , configurable: false - , get: function () { - if (_get_response) { - return _get_response; - } - _get_response = { - saveSubscriber: function (email, opts) { - var config = siteConfig['getresponse@daplie.com']; - var customFields = []; - Object.keys(config.customFields).forEach(function (name) { - if (typeof opts[name] !== 'undefined') { - customFields.push({ - customFieldId: config.customFields[name] - , value: [ String(opts[name]) ] - }); - } - }); - - return request({ - method: 'POST' - , url: 'https://api.getresponse.com/v3/contacts' - , headers: { 'X-Auth-Token': 'api-key ' + config.apiKey } - , json: true - , body: { - name: opts.name - , email: email - , ipAddress: opts.ipAddress - , campaign: { campaignId: config.campaignId } - , customFieldValues: customFields - } - }).then(function (resp) { - if (resp.statusCode === 202) { - return; - } - return PromiseA.reject(resp.body.message); - }); - } - }; - - return _get_response; - } - }); - - var Twilio = require('twilio'); - function twilioTel(/*opts*/) { - if (_twilio) { - return apiDeps.Promise.resolve(_twilio); - } - - _twilio = new Twilio.RestClient( - siteConfig['twilio.com'].live.id - , siteConfig['twilio.com'].live.auth - ); - return apiDeps.Promise.resolve(_twilio); - } - - // TODO shared memory db - var mailgunTokens = {}; - function validateMailgun(apiKey, timestamp, token, signature) { - // https://gist.github.com/coolaj86/81a3b61353d2f0a2552c - // (realized later) - // HAHA HAHA HAHAHAHAHA this is my own gist... so much more polite attribution - var scmp = require('scmp') - , crypto = require('crypto') - , mailgunExpirey = 15 * 60 * 1000 - , mailgunHashType = 'sha256' - , mailgunSignatureEncoding = 'hex' - ; - var actual - , adjustedTimestamp = parseInt(timestamp, 10) * 1000 - , fresh = (Math.abs(Date.now() - adjustedTimestamp) < mailgunExpirey) - ; - - if (!fresh) { - console.error('[mailgun] Stale Timestamp: this may be an attack'); - console.error('[mailgun] However, this is most likely your fault\n'); - console.error('[mailgun] run `ntpdate ntp.ubuntu.com` and check your system clock\n'); - console.error('[mailgun] System Time: ' + new Date().toString()); - console.error('[mailgun] Mailgun Time: ' + new Date(adjustedTimestamp).toString(), timestamp); - console.error('[mailgun] Delta: ' + (Date.now() - adjustedTimestamp)); - return false; - } - - if (mailgunTokens[token]) { - console.error('[mailgun] Replay Attack'); - return false; - } - - mailgunTokens[token] = true; - - setTimeout(function () { - delete mailgunTokens[token]; - }, mailgunExpirey + (5 * 1000)); - - actual = crypto.createHmac(mailgunHashType, apiKey) - .update(new Buffer(timestamp + token, 'utf8')) - .digest(mailgunSignatureEncoding) - ; - return scmp(signature, actual); - } - - function mailgunMail(/*opts*/) { - return apiDeps.Promise.resolve(req.getSiteMailer()); - } - function getResponseList() { - return apiDeps.Promise.resolve(req.GetResponse); - } - - // Twilio Parameters are often 26 long - var bodyParserTwilio = require('body-parser').urlencoded({ limit: '4kb', parameterLimit: 100, extended: false }); - // Mailgun has something like 50 parameters - var bodyParserMailgun = require('body-parser').urlencoded({ limit: '1024kb', parameterLimit: 500, extended: false }); - function bodyMultiParserMailgun (req, res, next) { - var multiparty = require('multiparty'); - var form = new multiparty.Form(); - - form.parse(req, function (err, fields/*, files*/) { - if (err) { - console.error('Error'); - console.error(err); - res.end("Couldn't parse form"); - return; - } - - var body; - req.body = req.body || {}; - Object.keys(fields).forEach(function (key) { - // TODO what if there were two of something? - // (even though there won't be) - req.body[key] = fields[key][0]; - }); - body = req.body; - - next(); - }); - } - - function daplieTel() { - return twilioTel().then(function (twilio) { - function sms(opts) { - // opts = { to, from, body } - return new apiDeps.Promise(function (resolve, reject) { - twilio.sendSms(opts, function (err, resp) { - if (err) { - reject(err); - return; - } - - resolve(resp); - }); - }); - } - - return { - sms: sms - , mms: function () { throw new Error('MMS Not Implemented'); } - }; - }); - } - - var caps = { - // - // Capabilities for APIs - // - 'email@daplie.com': mailgunMail // whichever mailer - , 'mailer@daplie.com': mailgunMail // whichever mailer - , 'mailgun@daplie.com': mailgunMail // specifically mailgun - , 'tel@daplie.com': daplieTel // whichever telephony service - , 'twilio@daplie.com': twilioTel // specifically twilio - , 'com.daplie.tel.twilio': twilioTel // deprecated alias - - , 'getresponse@daplie.com': getResponseList - // - // Webhook Parsers - // - //, 'mailgun.urlencoded@daplie.com': function (req, res, next) { ... } - , 'mailgun.parsers@daplie.com': function (req, res, next) { - var chunks = []; - - req.on('data', function (chunk) { - chunks.push(chunk); - }); - req.on('end', function () { - }); - - function verify() { - var body = req.body; - var mailconf = siteConfig['mailgun.org']; - - if (!body.timestamp) { - console.log('mailgun parser req.headers'); - console.log(req.headers); - chunks.forEach(function (datum) { - console.log('Length:', datum.length); - //console.log(datum.toString('utf8')); - }); - console.log('weird body'); - console.log(body); - } - - if (!validateMailgun(mailconf.apiKey, body.timestamp, body.token, body.signature)) { - console.error('Request came, but not from Mailgun'); - console.error(req.url); - console.error(req.headers); - res.send({ error: { message: 'Invalid signature. Are you even Mailgun?' } }); - return; - } - - next(); - } - - if (/urlencoded/.test(req.headers['content-type'])) { - console.log('urlencoded'); - bodyParserMailgun(req, res, verify); - } - else if (/multipart/.test(req.headers['content-type'])) { - console.log('multipart'); - bodyMultiParserMailgun(req, res, verify); - } - else { - console.log('no parser'); - next(); - } - } - , 'twilio.urlencoded@daplie.com': function (req, res, next) { - // TODO null for res and Promise instead of next? - return bodyParserTwilio(req, res, function () { - var signature = req.headers['x-twilio-signature']; - var auth = siteConfig['twilio.com'].live.auth; - var fullUrl = 'https://' + req.headers.host + req._walnutOriginalUrl; - var validSig = Twilio.validateRequest(auth, signature, fullUrl, req.body); - /* - console.log('Twilio Signature Check'); - console.log('auth', auth); - console.log('sig', signature); - console.log('fullUrl', fullUrl); - console.log(req.body); - console.log('valid', validSig); - */ - if (!validSig) { - res.statusCode = 401; - res.setHeader('Content-Type', 'text/xml'); - res.end('Invalid signature. Are you even Twilio?'); - return; - } - // TODO session via db req.body.CallId req.body.smsId - next(); - }); - } - }; - req.getSiteCapability = function (capname, opts, b, c) { - if (caps[capname]) { - return caps[capname](opts, b, c); - } - if (siteConfig[capname]) { - var service = siteConfig[capname].service || siteConfig[capname]; - if (caps[service]) { - return caps[service](opts, b, c); - } - } - return apiDeps.Promise.reject( - new Error("['" + req.clientApiUri + '/' + pkgId + "'] " - + "capability '" + capname + "' not implemented") - ); - }; - - req._walnutOriginalUrl = req.url; - // "/path/api/com.example/hello".replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/') => '/hello' - req.url = req.url.replace(/\/api\//, '').replace(/.*\/api\//, '').replace(/([^\/]*\/+)/, '/'); - next(); + return loadRestHelperApi(myConf, clientUrih, pkg, pkgId, pkgPath).then(function (stuff) { + return loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath).then(function (assetsHandler) { + stuff.assetsHandler = assetsHandler; + return stuff; }); }); - myApp.use('/public', function preHandler(req, res, next) { - // TODO authenticate or use guest user - req.isPublic = true; - next(); - }); - myApp.use('/accounts/:accountId', accountRequiredById); - myApp.use('/acl', accountRequired); - - // - // TODO handle /accounts/:accountId - // - return PromiseA.resolve(require(pkgPath).create({ - etcpath: xconfx.etcpath - }/*pkgConf*/, pkgDeps/*pkgDeps*/, myApp/*myApp*/)).then(function (handler) { - - //if (xconfx.debug) { console.log('[api.js] got handler'); } - myApp.use('/', function postHandler(req, res, next) { - req.url = req._walnutOriginalUrl; - next(); - }); - - localCache.pkgs[pkgId] = { pkgId: pkgId, pkg: pkg, handler: handler || myApp, createdAt: Date.now() }; - - pkgLinks.forEach(function (pkgLink) { - localCache.pkgs[pkgLink] = localCache.pkgs[pkgId]; - }); - - return localCache.pkgs[pkgId]; - }); }); }); } @@ -784,13 +986,14 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // sub.example.com/api should resolve to sub.example.com // example.com/subapp/api should resolve to example.com#subapp // sub.example.com/subapp/api should resolve to sub.example.com#subapp - var clientUrih = req.hostname.replace(/^api\./, '') + req.url.replace(/\/api\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, ''); - var clientApiUri = req.hostname + req.url.replace(/\/api\/.*/, '/').replace(/\/$/, ''); + var clientUrih = req.hostname.replace(/^(api|assets)\./, '') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, ''); + var clientApiUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); + var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'assets.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); // Canonical package names // '/api/com.daplie.hello/hello' should resolve to 'com.daplie.hello' // '/subapp/api/com.daplie.hello/hello' should also 'com.daplie.hello' // '/subapp/api/com.daplie.hello/' may exist... must be a small api - var pkgId = req.url.replace(/.*\/api\//, '').replace(/^\//, '').replace(/\/.*/, ''); + var pkgId = req.url.replace(/.*\/(api|assets)\//, '').replace(/^\//, '').replace(/\/.*/, ''); var now = Date.now(); var hasBeenHandled = false; @@ -801,6 +1004,12 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { , writable: false , value: 'https://' + clientApiUri + '/api/' + pkgId }); + Object.defineProperty(req, 'assetsUrlPrefix', { + enumerable: true + , configurable: false + , writable: false + , value: 'https://' + clientAssetsUri + '/assets/' + pkgId + }); Object.defineProperty(req, 'experienceId', { enumerable: true , configurable: false @@ -813,6 +1022,12 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { , writable: false , value: clientApiUri }); + Object.defineProperty(req, 'clientAssetsUri', { + enumerable: true + , configurable: false + , writable: false + , value: clientAssetsUri + }); Object.defineProperty(req, 'apiId', { enumerable: true , configurable: false @@ -838,19 +1053,36 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { }); // TODO cache permission (although the FS is already cached, NBD) - var promise = isThisClientAllowedToUseThisPkg(xconfx, clientUrih, pkgId).then(function (yes) { + var promise = isThisClientAllowedToUseThisPkg(req, xconfx, clientUrih, pkgId).then(function (yes) { //if (xconfx.debug) { console.log('[api.js] azp is allowed?', yes); } if (!yes) { notConfigured(req, res); return null; } + function handleWithHandler() { + if (/\/assets\//.test(req.url) || /(^|\.)assets\./.test(req.hostname)) { + if (localCache.assets[pkgId]) { + if ('function' !== typeof localCache.assets[pkgId].handler) { console.log('localCache.assets[pkgId]'); console.log(localCache.assets[pkgId]); } + localCache.assets[pkgId].handler(req, res, next); + } else { + next(); + return true; + } + } else { + localCache.rests[pkgId].handler(req, res, next); + } + } + if (localCache.rests[pkgId]) { - localCache.rests[pkgId].handler(req, res, next); + if (handleWithHandler()) { + return; + } hasBeenHandled = true; if (now - localCache.rests[pkgId].createdAt > staleAfter) { localCache.rests[pkgId] = null; + localCache.assets[pkgId] = null; } } @@ -866,13 +1098,16 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { } localCache.rests[pkgId] = { handler: myHandler.handler, createdAt: now }; + localCache.assets[pkgId] = { handler: myHandler.assetsHandler, createdAt: now }; if (!hasBeenHandled) { - //if (xconfx.debug) { console.log('[api.js] not configured'); } - myHandler.handler(req, res, next); + if (handleWithHandler()) { + return; + } } }); } }); + rejectableRequest(req, res, promise, "[walnut@daplie.com] load api package"); }); }; diff --git a/lib/main.js b/lib/main.js index c3d3515..8fd8eff 100644 --- a/lib/main.js +++ b/lib/main.js @@ -1,6 +1,6 @@ '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 path = require('path'); 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/ app.use('/api', require('connect-send-error').error()); + app.use('/assets', require('connect-send-error').error()); app.use('/', function (req, res, next) { - // If this doesn't look like an API we can move along - if (!/\/api(\/|$)/.test(req.url)) { - // /^api\./.test(req.hostname) && + // If this doesn't look like an API or assets we can move along + + /* + 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(); return; } @@ -325,6 +342,7 @@ module.exports.create = function (app, xconfx, apiFactories, apiDeps, errorIfApi return; }); app.use('/', errorIfApi); + app.use('/', errorIfAssets); app.use('/', serveStatic); app.use('/', serveApps); diff --git a/lib/oauth3.js b/lib/oauth3.js index fc8ea59..c89df8d 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -181,6 +181,50 @@ function deepFreeze(obj) { Object.freeze(obj); } +function cookieOauth3(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 = 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. + return PromiseA.resolve(sub || hash); + }; + }).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(req, res, next) { req.oauth3 = {}; @@ -215,14 +259,15 @@ function attachOauth3(req, res, next) { }; }).then(function () { deepFreeze(req.oauth3); - Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); + //Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); next(); }, function (err) { - console.error('[walnut] lib/oauth3 error:'); + 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; diff --git a/lib/worker.js b/lib/worker.js index ce7d795..313c6d0 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -150,6 +150,10 @@ module.exports.create = function (webserver, xconfx, state) { models: models // TODO don't let packages use this directly , Promise: PromiseA + , dns: PromiseA.promisifyAll(require('dns')) + , crypto: PromiseA.promisifyAll(require('crypto')) + , fs: PromiseA.promisifyAll(require('fs')) + , path: require('path') }; var apiFactories = { memstoreFactory: { create: scopeMemstore } @@ -180,7 +184,7 @@ module.exports.create = function (webserver, xconfx, state) { function setupMain() { if (xconfx.debug) { console.log('[main] setup'); } 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'); } // TODO process.send({}); }); @@ -225,6 +229,24 @@ module.exports.create = function (webserver, xconfx, state) { 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) { if (!/^api\./.test(req.headers.host)) { next(); @@ -240,7 +262,25 @@ module.exports.create = function (webserver, xconfx, state) { 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'); @@ -258,8 +298,11 @@ module.exports.create = function (webserver, xconfx, state) { })); app.use('/api', recase); + var cookieParser = require('cookie-parser'); // signing is done in JWT + app.set('trust proxy', ['loopback', 'linklocal', 'uniquelocal']); 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) { if (!(req.encrypted || req.secure)) { // did not come from https From 0492c66a8b3f1ac1fb500989ee85e57dd5e237bd Mon Sep 17 00:00:00 2001 From: aj Date: Fri, 1 Sep 2017 00:49:08 +0000 Subject: [PATCH 09/12] restructure of some code --- lib/apis.js | 211 ++++++++++++++++++++++++++++------------------------ 1 file changed, 115 insertions(+), 96 deletions(-) diff --git a/lib/apis.js b/lib/apis.js index f576953..2737ce9 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -222,6 +222,53 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { rejectableRequest(req, res, promise, "[walnut@daplie.com] required account (not /public)"); } + function grantsRequired(grants) { + if (!Array.isArray(grants)) { + throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); + } + + if (!grants.length) { + return function (req, res, next) { + next(); + }; + } + + return function (req, res, next) { + var tokenScopes; + + if (!(req.oauth3 || req.oauth3.token)) { + // TODO some error generator for standard messages + res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); + return; + } + var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; + if ('string' !== typeof scope) { + res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); + return; + } + + tokenScopes = scope.split(/[,\s]+/mg); + if (-1 !== tokenScopes.indexOf('*')) { + // has full account access + next(); + return; + } + + // every grant in the array must be present, though some grants can be satisfied + // by multiple scopes. + var missing = grants.filter(function (grant) { + return !grant.split('|').some(function (scp) { + return tokenScopes.indexOf(scp) !== -1; + }); + }); + if (missing.length) { + res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); + return; + } + + next(); + }; + } function loadRestHelperApi(myConf, clientUrih, pkg, pkgId, pkgPath) { var pkgLinks = []; pkgLinks.push(pkgId); @@ -256,53 +303,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { myApp = express(); myApp.handlePromise = promisableRequest; myApp.handleRejection = rejectableRequest; - myApp.grantsRequired = function (grants) { - if (!Array.isArray(grants)) { - throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); - } - - if (!grants.length) { - return function (req, res, next) { - next(); - }; - } - - return function (req, res, next) { - var tokenScopes; - - if (!(req.oauth3 || req.oauth3.token)) { - // TODO some error generator for standard messages - res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); - return; - } - var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; - if ('string' !== typeof scope) { - res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); - return; - } - - tokenScopes = scope.split(/[,\s]+/mg); - if (-1 !== tokenScopes.indexOf('*')) { - // has full account access - next(); - return; - } - - // every grant in the array must be present, though some grants can be satisfied - // by multiple scopes. - var missing = grants.filter(function (grant) { - return !grant.split('|').some(function (scp) { - return tokenScopes.indexOf(scp) !== -1; - }); - }); - if (missing.length) { - res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); - return; - } - - next(); - }; - }; + myApp.grantsRequired = grantsRequired; myApp.use('/', require('./oauth3').attachOauth3); @@ -591,11 +592,64 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { }); } + var settingsPromise = PromiseA.resolve(); + function manageSiteSettings(section) { + + var submanager; + var manager = { + set: function (section, value) { + if ('email@daplie.com' === section) { + section = 'mailgun.org'; + } + + settingsPromise = settingsPromise.then(function () { + return manager.get().then(function () { + siteConfig[section] = value; + + var siteConfigPath = path.join(xconfx.appConfigPath, clientUrih); + return mkdirpAsync(siteConfigPath).then(function () { + return fs.writeFileAsync(path.join(siteConfigPath, 'config.json'), JSON.stringify(siteConfig), 'utf8'); + }); + }); + }); + return settingsPromise; + } + , get: function (section) { + if ('email@daplie.com' === section) { + section = 'mailgun.org'; + } + + settingsPromise = settingsPromise.then(function () { + return getSiteConfig(clientUrih).then(function (_siteConfig) { + siteConfig = _siteConfig; + return PromiseA.resolve((_siteConfig || {})[section]); + }); + }); + return settingsPromise; + } + }; + + submanager = manager; + if (section) { + submanager = { + set: function (value) { + return manager.set(section, value); + } + , get: function () { + return manager.get(section); + } + }; + } + + return apiDeps.Promise.resolve(submanager); + } + var caps = { // // Capabilities for APIs // - 'email@daplie.com': mailgunMail // whichever mailer + 'settings.site@daplie.com': manageSiteSettings + , 'email@daplie.com': mailgunMail // whichever mailer , 'mailer@daplie.com': mailgunMail // whichever mailer , 'mailgun@daplie.com': mailgunMail // specifically mailgun , 'tel@daplie.com': daplieTel // whichever telephony service @@ -736,7 +790,13 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { function loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath) { var myApp; var pkgDeps = {}; - var pkgRestAssets = require(path.join(pkgPath, 'assets.js')); + var pkgRestAssets; + + try { + pkgRestAssets = require(path.join(pkgPath, 'assets.js')); + } catch(e) { + return PromiseA.reject(e); + } Object.keys(apiDeps).forEach(function (key) { pkgDeps[key] = apiDeps[key]; @@ -758,53 +818,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { myApp = express(); myApp.handlePromise = promisableRequest; myApp.handleRejection = rejectableRequest; - myApp.grantsRequired = function (grants) { - if (!Array.isArray(grants)) { - throw new Error("Usage: app.grantsRequired([ 'name|altname|altname2', 'othergrant' ])"); - } - - if (!grants.length) { - return function (req, res, next) { - next(); - }; - } - - return function (req, res, next) { - var tokenScopes; - - if (!(req.oauth3 || req.oauth3.token)) { - // TODO some error generator for standard messages - res.send({ error: { message: "You must be logged in", code: "E_NO_AUTHN" } }); - return; - } - var scope = req.oauth3.token.scope || req.oauth3.token.scp || req.oauth3.token.grants; - if ('string' !== typeof scope) { - res.send({ error: { message: "Token must contain a grants string in 'scope'", code: "E_NO_GRANTS" } }); - return; - } - - tokenScopes = scope.split(/[,\s]+/mg); - if (-1 !== tokenScopes.indexOf('*')) { - // has full account access - next(); - return; - } - - // every grant in the array must be present, though some grants can be satisfied - // by multiple scopes. - var missing = grants.filter(function (grant) { - return !grant.split('|').some(function (scp) { - return tokenScopes.indexOf(scp) !== -1; - }); - }); - if (missing.length) { - res.send({ error: { message: "Token missing required grants: '" + missing.join(',') + "'", code: "E_NO_GRANTS" } }); - return; - } - - next(); - }; - }; + myApp.grantsRequired = grantsRequired; myApp.use('/', require('./oauth3').cookieOauth3); myApp.use('/', function (req, res, next) { @@ -937,6 +951,10 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { return loadRestHelperAssets(myConf, clientUrih, pkg, pkgId, pkgPath).then(function (assetsHandler) { stuff.assetsHandler = assetsHandler; return stuff; + }, function (err) { + console.error('[lib/api.js] no assets handler:'); + console.error(err); + return stuff; }); }); }); @@ -989,6 +1007,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { var clientUrih = req.hostname.replace(/^(api|assets)\./, '') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, ''); var clientApiUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'assets.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); + //var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); // Canonical package names // '/api/com.daplie.hello/hello' should resolve to 'com.daplie.hello' // '/subapp/api/com.daplie.hello/hello' should also 'com.daplie.hello' From da68c102cd3527c9b09d28e72718a2ebf8010304 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Fri, 11 Aug 2017 18:25:34 -0600 Subject: [PATCH 10/12] stop double printing uncaught error stacks --- boot/worker.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/boot/worker.js b/boot/worker.js index 3b1223a..78167de 100644 --- a/boot/worker.js +++ b/boot/worker.js @@ -149,9 +149,10 @@ module.exports.create = function () { process.on('unhandledRejection', function (err) { // this should always throw // (it means somewhere we're not using bluebird by accident) - console.error('[caught] [unhandledRejection]'); - console.error(Object.keys(err)); - console.error(err); + console.error('[caught unhandledRejection]:', err.message || ''); + Object.keys(err).forEach(function (key) { + console.log('\t'+key+': '+err[key]); + }); console.error(err.stack); }); process.on('rejectionHandled', function (msg) { From 08cb6c2d08b6a42b9572ec613e81a0480c9f4486 Mon Sep 17 00:00:00 2001 From: tigerbot Date: Tue, 12 Sep 2017 11:55:47 -0600 Subject: [PATCH 11/12] made request error logging more DRY --- lib/common.js | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/lib/common.js b/lib/common.js index 4c74c4a..701c0a3 100644 --- a/lib/common.js +++ b/lib/common.js @@ -1,20 +1,21 @@ 'use strict'; -module.exports.rejectableRequest = function rejectableRequest(req, res, promise, msg) { +function rejectableRequest(req, res, promise, msg) { return promise.error(function (err) { res.error(err); }).catch(function (err) { console.error('[ERROR] \'' + msg + '\''); - console.error(err.message); - console.error(err.stack); + // The stack contains the message as well, so no need to log the message when we log the stack + console.error(err.stack || err.message || JSON.stringify(err)); res.error(err); }); -}; +} +module.exports.rejectableRequest = rejectableRequest; module.exports.promisableRequest = module.exports.promiseRequest = function promiseRequest(req, res, promise, msg) { - return promise.then(function (result) { + promise = promise.then(function (result) { if (result._cache) { res.setHeader('Cache-Control', 'public, max-age=' + (result._cache / 1000)); 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; } 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); }; From 976761b6e048a5d9f0fbc7e887c7481440063f9e Mon Sep 17 00:00:00 2001 From: aj Date: Tue, 12 Sep 2017 22:32:33 +0000 Subject: [PATCH 12/12] [WIP] retrieve sub from db --- lib/apis.js | 75 ++++++++++++++++++++++++++++++++++++++++++++------- lib/oauth3.js | 65 +++++++++++++++++++++++++++++++++----------- lib/worker.js | 11 ++++++++ 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/lib/apis.js b/lib/apis.js index 2737ce9..76aba6e 100644 --- a/lib/apis.js +++ b/lib/apis.js @@ -161,7 +161,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { function accountRequired(req, res, next) { // if this already has auth, great - if (req.oauth3.ppid) { + if (req.oauth3.ppid && req.oauth3.accountIdx) { next(); return; } @@ -305,7 +305,27 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { myApp.handleRejection = rejectableRequest; myApp.grantsRequired = grantsRequired; - myApp.use('/', require('./oauth3').attachOauth3); + function getSitePackageStoreProp(otherPkgId) { + var restPath = path.join(myConf.restPath, otherPkgId); + var apiPath = path.join(myConf.apiPath, otherPkgId); + var dir; + + // TODO usage package.json as a falback if the standard location is not used + try { + dir = require(path.join(apiPath, 'models.js')); + } catch(e) { + dir = require(path.join(restPath, 'models.js')); + } + + return getSiteStore(clientUrih, otherPkgId, dir); + } + + function attachOauth3(req, res, next) { + return getSitePackageStoreProp('issuer@oauth3.org').then(function (Models) { + return require('./oauth3').attachOauth3(Models, req, res, next); + }); + } + myApp.use('/', attachOauth3); // TODO delete these caches when config changes var _stripe; @@ -318,7 +338,9 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { //if (xconfx.debug) { console.log('[api.js] loading handler prereqs'); } return getSiteConfig(clientUrih).then(function (siteConfig) { //if (xconfx.debug) { console.log('[api.js] loaded handler site config'); } - Object.defineProperty(req, 'getSiteMailer', { + + // Use getSiteCapability('email@daplie.com') instead + Object.defineProperty(req, 'getSiteMailer' /*deprecated*/, { enumerable: true , configurable: false , writable: false @@ -355,6 +377,13 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { } }); + Object.defineProperty(req, 'getSitePackageStore', { + enumerable: true + , configurable: false + , writable: false + , value: getSitePackageStoreProp + }); + Object.defineProperty(req, 'getSiteStore', { enumerable: true , configurable: false @@ -820,14 +849,37 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { myApp.handleRejection = rejectableRequest; myApp.grantsRequired = grantsRequired; - myApp.use('/', require('./oauth3').cookieOauth3); + function otherGetSitePackageStoreProp(otherPkgId) { + var restPath = path.join(myConf.restPath, otherPkgId); + var apiPath = path.join(myConf.apiPath, otherPkgId); + var dir; + + // TODO usage package.json as a falback if the standard location is not used + try { + dir = require(path.join(apiPath, 'models.js')); + } catch(e) { + dir = require(path.join(restPath, 'models.js')); + } + + return getSiteStore(clientUrih, otherPkgId, dir); + } + myApp.use('/', function cookieAttachOauth3(req, res, next) { + return otherGetSitePackageStoreProp('issuer@oauth3.org').then(function (Models) { + return require('./oauth3').cookieOauth3(Models, req, res, next); + }); + }); myApp.use('/', function (req, res, next) { console.log('########################################### session ###############################'); console.log('req.url', req.url); console.log('req.oauth3', req.oauth3); next(); }); - myApp.post('/assets/issuer@oauth3.org/session', require('./oauth3').attachOauth3, function (req, res) { + function otherAttachOauth3(req, res, next) { + return otherGetSitePackageStoreProp('issuer@oauth3.org').then(function (Models) { + return require('./oauth3').attachOauth3(Models, req, res, next); + }); + } + myApp.post('/assets/issuer@oauth3.org/session', otherAttachOauth3, function (req, res) { console.log('get the session'); console.log(req.url); console.log("req.cookies:"); @@ -1004,7 +1056,8 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { // sub.example.com/api should resolve to sub.example.com // example.com/subapp/api should resolve to example.com#subapp // sub.example.com/subapp/api should resolve to sub.example.com#subapp - var clientUrih = req.hostname.replace(/^(api|assets)\./, '') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/+/g, '#').replace(/#$/, ''); + var appUri = req.hostname.replace(/^(api|assets)\./, '') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); + var clientUrih = appUri.replace(/\/+/g, '#').replace(/#$/, ''); var clientApiUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'assets.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); //var clientAssetsUri = req.hostname.replace(/^(api|assets)\./, 'api.') + req.url.replace(/\/(api|assets)\/.*/, '/').replace(/\/$/, ''); @@ -1016,7 +1069,12 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { var now = Date.now(); var hasBeenHandled = false; - // Existing (Deprecated) + Object.defineProperty(req, 'clientUrl', { + enumerable: true + , configurable: false + , writable: false + , value: (req.headers.referer || ('https://' + appUri)).replace(/\/$/, '').replace(/\?.*/, '') + }); Object.defineProperty(req, 'apiUrlPrefix', { enumerable: true , configurable: false @@ -1029,7 +1087,7 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { , writable: false , value: 'https://' + clientAssetsUri + '/assets/' + pkgId }); - Object.defineProperty(req, 'experienceId', { + Object.defineProperty(req, 'experienceId' /*deprecated*/, { enumerable: true , configurable: false , writable: false @@ -1054,7 +1112,6 @@ module.exports.create = function (xconfx, apiFactories, apiDeps) { , value: pkgId }); - // New Object.defineProperty(req, 'clientUrih', { enumerable: true , configurable: false diff --git a/lib/oauth3.js b/lib/oauth3.js index c89df8d..63f1427 100644 --- a/lib/oauth3.js +++ b/lib/oauth3.js @@ -2,6 +2,42 @@ 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; @@ -181,7 +217,7 @@ function deepFreeze(obj) { Object.freeze(obj); } -function cookieOauth3(req, res, next) { +function cookieOauth3(Models, req, res, next) { req.oauth3 = {}; var token = req.cookies.jwt; @@ -205,11 +241,7 @@ function cookieOauth3(req, res, next) { hash = hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+/g, ''); req.oauth3.accountHash = hash; - req.oauth3.rescope = 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. - return PromiseA.resolve(sub || hash); - }; + req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid); }).then(function () { deepFreeze(req.oauth3); //Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); @@ -225,7 +257,7 @@ function cookieOauth3(req, res, next) { }); } -function attachOauth3(req, res, next) { +function attachOauth3(Models, req, res, next) { req.oauth3 = {}; extractAccessToken(req).then(function (token) { @@ -245,20 +277,21 @@ function attachOauth3(req, res, next) { } var ppid = decoded.sub || decoded.ppid || decoded.appScopedId; + var fullPpid = ppid+'@'+decoded.iss; 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, ''); + // 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 = 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. - return PromiseA.resolve(sub || hash); - }; + req.oauth3.rescope = generateRescope(req, Models, decoded, fullPpid, ppid); + + console.log('############### assigned req.oauth3:'); + console.log(req.oauth3); }).then(function () { - deepFreeze(req.oauth3); + //deepFreeze(req.oauth3); //Object.defineProperty(req, 'oauth3', {configurable: false, writable: false}); next(); }, function (err) { diff --git a/lib/worker.js b/lib/worker.js index 313c6d0..abe224a 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -154,6 +154,17 @@ module.exports.create = function (webserver, xconfx, state) { , 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 = { memstoreFactory: { create: scopeMemstore }