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",