From 7fa2fdfc11a4971d9c3e3068b68c4cc56dd696cd Mon Sep 17 00:00:00 2001 From: tigerbot Date: Mon, 24 Jul 2017 17:10:41 -0600 Subject: [PATCH] separated the code into different files --- accounts.js | 355 ++++++++++++++++++++++++++++++++++ common.js | 39 ++++ grants.js | 76 ++++++++ jwks.js | 109 +++++++++++ rest.js | 539 +--------------------------------------------------- 5 files changed, 582 insertions(+), 536 deletions(-) create mode 100644 accounts.js create mode 100644 common.js create mode 100644 grants.js create mode 100644 jwks.js diff --git a/accounts.js b/accounts.js new file mode 100644 index 0000000..5c2067d --- /dev/null +++ b/accounts.js @@ -0,0 +1,355 @@ +'use strict'; + +var crypto = require('crypo'); +var PromiseA = require('bluebird'); +var OpErr = PromiseA.OperationalError; + +function makeB64UrlSafe(b64) { + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); +} + +function retrieveOtp(codeStore, codeId) { + return codeStore.get(codeId).then(function (code) { + if (!code) { + return null; + } + + var expires = (new Date(code.expires)).valueOf(); + if (!expires || Date.now() > expires) { + return codeStore.destroy(codeId).then(function () { + return null; + }); + } + + return code; + }); +} +function validateOtp(codeStore, codeId, token) { + if (!codeId) { + return PromiseA.reject(new Error("Must provide authcode ID")); + } + if (!token) { + return PromiseA.reject(new Error("Must provide authcode code")); + } + return codeStore.get(codeId).then(function (code) { + if (!code) { + throw new OpErr('authcode specified does not exist or has expired'); + } + + return PromiseA.resolve().then(function () { + var attemptsLeft = 3 - (code.attempts && code.attempts.length || 0); + if (attemptsLeft <= 0) { + throw new OpErr('you have tried to authorize this code too many times'); + } + if (code.code !== token) { + throw new OpErr('you have entered the code incorrectly. '+attemptsLeft+' attempts remaining'); + } + // TODO: maybe impose a rate limit, although going fast doesn't help you break the + // system when you can only try 3 times total. + }).then(function () { + return codeStore.destroy(codeId).then(function () { + return code; + }); + }, function (err) { + code.attempts = code.attempts || []; + code.attempts.unshift(new Date()); + + return codeStore.upsert(codeId, code).then(function () { + return PromiseA.reject(err); + }, function () { + return PromiseA.reject(err); + }); + }); + }); +} + +function getOrCreate(store, username) { + return store.IssuerOauth3OrgAccounts.get(username).then(function (account) { + if (account) { + return account; + } + + account = { + username: username, + accountId: makeB64UrlSafe(crypto.randomBytes(32).toString('base64')), + }; + return store.IssuerOauth3OrgAccounts.create(username, account).then(function () { + // TODO: put sort sort of email notification to the server managers? + return account; + }); + }); +} +function getPrivKey(store, experienceId) { + return store.IssuerOauth3OrgPrivateKeys.get(experienceId).then(function (jwk) { + if (jwk) { + return jwk; + } + + var keyPair = require('elliptic').ec('p256').genKeyPair(); + jwk = { + kty: 'EC', + crv: 'P-256', + alg: 'ES256', + kid: experienceId, + x: makeB64UrlSafe(keyPair.getPublic().getX().toArrayLike(Buffer).toString('base64')), + y: makeB64UrlSafe(keyPair.getPublic().getY().toArrayLike(Buffer).toString('base64')), + d: makeB64UrlSafe(keyPair.getPrivate().toArrayLike(Buffer).toString('base64')), + }; + + return store.IssuerOauth3OrgPrivateKeys.upsert(experienceId, jwk).then(function () { + return jwk; + }); + }); +} + +function timespan(duration, max) { + var timestamp = Math.floor(Date.now() / 1000); + + if (!duration) { + return; + } + if (typeof duration === 'string') { + duration = Math.floor(require('ms')(duration) / 1000) || 0; + } + if (typeof duration !== 'number') { + return 0; + } + // Handle the case where the user gave us a timestamp instead of duration for the expiration. + // Also make the maximum explicitly defined expiration as one year. + if (duration > 31557600) { + if (duration > timestamp) { + return duration - timestamp; + } else { + return 31557600; + } + } + + if (max && timestamp+duration > max) { + return max - timestamp; + } + return duration; +} + +function create(app) { + var restful = {}; + + restful.sendOtp = function (req, res) { + var params = req.body; + var promise = PromiseA.resolve().then(function () { + if (!params || !params.username) { + throw new OpErr("must provide the email address as 'username' in the body"); + } + if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { + throw new OpErr("only email one-time login codes are supported at this time"); + } + params.username_type = 'email'; + + return req.getSiteStore(); + }).then(function (store) { + var codeStore = store.IssuerOauth3OrgCodes; + var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); + codeId = makeB64UrlSafe(codeId); + + return retrieveOtp(codeStore, codeId).then(function (code) { + if (code) { + return code; + } + + var token = ''; + while (!/^\d{4}-\d{4}-\d{4}$/.test(token)) { + // Most of the number we can generate this was start with 1 (and no matter what can't + // start with 0), so we don't use the very first digit. Also basically all of the + // numbers are too big to accurately store in JS floats, so we limit the trailing 0's. + token = (parseInt(crypto.randomBytes(8).toString('hex'), 16)).toString() + .replace(/0+$/, '0').replace(/\d(\d{4})(\d{4})(\d{4}).*/, '$1-$2-$3'); + } + code = { + id: codeId, + code: token, + expires: new Date(Date.now() + 20*60*1000), + }; + return codeStore.upsert(codeId, code).then(function (){ + return code; + }); + }); + }).then(function (code) { + var emailParams = { + to: params.username, + from: 'login@daplie.com', // opts.mailer.defaults.system + replyTo: 'hello@daplie.com', + subject: "Use " + code.code + " as your Login Code", // message.Subject + text: code.code + " is your Login Code." // message['stripped-html'] + }; + emailParams['h:Reply-To'] = emailParams.replyTo; + + return req.getSiteMailer().sendMailAsync(emailParams).then(function () { + return { + code_id: code.id, + expires: code.expires, + created: new Date(parseInt(code.createdAt, 10) || code.createdAt), + }; + }); + }); + + app.handlePromise(req, res, promise, '[issuer@oauth3.org] send one-time-password'); + }; + + restful.createToken = function (req, res) { + var store; + var promise = req.getSiteStore().then(function (_store) { + store = _store; + if (!req.body || !req.body.grant_type) { + throw new OpErr("missing 'grant_type' from the body"); + } + + if (req.body.grant_type === 'password') { + restful.createToken.password(req); + } + if (req.body.grant_type === 'issuer_token') { + restful.createToken.issuerToken(req); + } + if (req.body.grant_type === 'refresh_token') { + restful.createToken.refreshToken(req); + } + + throw new OpErr("unknown or un-implemented grant_type '"+req.body.grant_type+"'"); + }).then(function (token_info) { + token_info.iss = req.experienceId; + if (!token_info.aud) { + throw new OpErr("missing required token field 'aud'"); + } + if (!token_info.azp) { + throw new OpErr("missing required token field 'azp'"); + } + + if (token_info.iss === token_info.azp) { + // We don't have normal grants for the issuer, so we don't need to look the + // azpSub or the grants up in the database. + token_info.azpSub = token_info.sub; + token_info.scope = ''; + return token_info; + } + + var search = {}; + ['sub', 'azp', 'azpSub'].forEach(function (key) { + if (token_info[key]) { + search[key] = token_info[key]; + } + }); + return store.IssuerOauth3OrgGrants.find(search).then(function (grants) { + if (!grants.length) { + throw new OpErr("'"+token_info.azp+"' not given any grants from '"+(token_info.sub || token_info.azpSub)+"'"); + } + if (grants.length > 1) { + throw new Error("unexpected resource collision: too many relevant grants"); + } + var grant = grants[0]; + Object.keys(grant).forEach(function (key) { + token_info[key] = grant[key]; + }); + return token_info; + }); + }).then(function (token_info) { + return getPrivKey(store, req.experienceId).then(function (jwk) { + var pem = require('jwk-to-pem')(jwk, { private: true }); + var payload = { + // standard + iss: token_info.iss, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.1 + aud: token_info.aud, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.3 + azp: token_info.azp, + sub: token_info.azpSub, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.2 + // extended + scp: token_info.scope, + }; + var opts = { + algorithm: jwk.alg, + header: { + kid: jwk.kid + } + }; + var accessOpts = {}; + // We set `expiresIn` like this to make it possible to send `null` and `exp` to have + // no expiration while still having a default of 1 day. + if (req.body.hasOwnProperty('exp')) { + accessOpts.expiresIn = timespan(req.body.exp, token_info.exp); + } else { + accessOpts.expiresIn = timespan('1d', token_info.exp); + } + var refreshOpts = {}; + refreshOpts.expiresIn = timespan(req.body.refresh_exp, token_info.exp); + + var jwt = require('jsonwebtoken'); + var result = {}; + result.scope = token_info.scope; + result.access_token = jwt.sign(payload, pem, Object.assign(accessOpts, opts)); + if (req.body.refresh_token) { + if (token_info.refresh_token) { + result.refresh_token = token_info.refresh_token; + } else { + result.refresh_token = jwt.sign(payload, pem, Object.assign(refreshOpts, opts)); + } + } + return result; + }); + }); + + app.handlePromise(req, res, promise, '[issuer@oauth3.org] create tokens'); + }; + restful.createToken.password = function (req) { + var params = req.body; + if (!params || !params.username) { + return PromiseA.reject(PromiseA.OperationalError("must provide the email address as 'username' in the body")); + } + if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { + return PromiseA.reject(PromiseA.OperationalError("only email one-time login codes are supported at this time")); + } + params.username_type = 'email'; + if (!params.password) { + return PromiseA.reject(new OpErr("request missing 'password'")); + } + + var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); + codeId = makeB64UrlSafe(codeId); + return req.getSiteStore().then(function (store) { + return validateOtp(store.IssuerOauth3OrgCodes, codeId, params.password) + .then(function () { + return getOrCreate(store, params.username); + }).then(function (account) { + return { + sub: account.accountId, + aud: req.params.aud || req.body.aud || req.experienceId, + azp: req.params.azp || req.body.azp || req.experienceId, + }; + }); + }); + }; + restful.createToken.issuerToken = function (req) { + return require('./common').checkIsserToken(req, req.params.sub || req.body.sub).then(function (sub) { + return { + sub: sub, + aud: req.params.aud || req.body.aud, + azp: req.params.azp || req.body.azp, + exp: req.oauth3.token.exp, + }; + }); + }; + restful.createToken.refreshToken = function (req) { + return PromiseA.resolve().then(function () { + if (!req.body.refresh_token) { + throw new OpErr("missing refresh token"); + } + + return req.oauth3.verifyAsync(req.body.refresh_token).then(function (token) { + return { + sub: token.sub, + aud: token.aud, + azp: token.azp, + exp: token.exp, + refresh_token: req.body.refresh_token, + }; + }); + }); + }; +} + +module.exports.create = create; diff --git a/common.js b/common.js new file mode 100644 index 0000000..594b66b --- /dev/null +++ b/common.js @@ -0,0 +1,39 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var OpErr = PromiseA.OperationalError; + +function checkIsserToken(req, expectedSub) { + if (!req.oauth3 || !req.oauth3.verifyAsync) { + return PromiseA.reject(new OpErr("request requires a token for authorization")); + } + return req.oauth3.verifyAsync().then(function (token) { + // Now that we've confirmed the token is valid we also need to make sure the issuer, audience, + // and authorized party are all us, because no other app should be managing user identity. + if (token.iss !== req.experienceId || token.aud !== token.iss || token.azp !== token.iss) { + throw new OpErr("token does not allow access to requested resource"); + } + + var sub = token.sub || token.ppid || (token.acx && (token.acx.id || token.acx.appScopedId)); + if (!sub) { + if (!expectedSub || !Array.isArray(token.axs) || !token.axs.length) { + throw new OpErr("no account pairwise identifier"); + } + + var allowed = token.axs.some(function (acc) { + return expectedSub === (acc.id || acc.ppid || acc.appScopedId); + }); + if (!allowed) { + throw new OpErr("no account pairwise identifier matching '" + expectedSub + "'"); + } + sub = expectedSub; + } + + if (expectedSub && expectedSub !== sub) { + throw new OpErr("token does not allow access to resources for '"+expectedSub+"'"); + } + return sub; + }); +} + +module.exports.checkIsserToken = checkIsserToken; diff --git a/grants.js b/grants.js new file mode 100644 index 0000000..b83b36e --- /dev/null +++ b/grants.js @@ -0,0 +1,76 @@ +'use strict'; + +var PromiseA = require('bluebird'); +var OpErr = PromiseA.OperationalError; + + +function trim(grant) { + return { + sub: grant.sub, + azp: grant.azp, + // azpSub: grant.azpSub, + scope: grant.scope, + updatedAt: parseInt(grant.updatedAt, 10), + }; +} + +function create(app) { + var restful; + + restful.getOne = function (req, res) { + var promise = req.Store.get(req.params.sub+'/'+req.params.azp).then(function (grant) { + if (!grant) { + throw new OpErr('no grants found'); + } + return trim(grant); + }); + + app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); + }; + + restful.getAll = function (req, res) { + var promise = req.Store.find({ sub: req.params.sub }).then(function (results) { + return results.map(trim).sort(function (grantA, grantB) { + return (grantA.azp < grantB.azp) ? -1 : 1; + }); + }); + + app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); + }; + + restful.saveNew = function (req, res) { + var promise = PromiseA.resolve().then(function () { + if (typeof req.body.scope !== 'string' || typeof req.body.sub !== 'string') { + throw new OpErr("malformed request: 'sub' and 'scope' must be strings"); + } + return req.Store.find({ azpSub: req.body.sub }); + }).then(function (existing) { + if (existing.length) { + if (existing.length > 1) { + throw new OpErr("pre-existing PPID collision detected"); + } else if (existing[0].sub !== req.params.sub || existing[0].azp !== req.params.azp) { + throw new OpErr("PPID collision detected, cannot save authorized party's sub"); + } + } + + var grant = { + sub: req.params.sub, + azp: req.params.azp, + azpSub: req.body.sub, + scope: req.body.scope.split(/[+ ,]+/g).join(','), + }; + return req.Store.upsert(grant.sub+'/'+grant.azp, grant); + }).then(function () { + return {success: true}; + }); + + app.handlePromise(req, res, promise, '[issuer@oauth3.org] save grants'); + }; + + return { + trim: trim, + restful: restful, + }; +} + +module.exports.create = create; diff --git a/jwks.js b/jwks.js new file mode 100644 index 0000000..15cd308 --- /dev/null +++ b/jwks.js @@ -0,0 +1,109 @@ +'use strict'; + +var crypto = require('crypto'); +var PromiseA = require('bluebird'); +var OpErr = PromiseA.OperationalError; + +function makeB64UrlSafe(b64) { + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); +} + +function thumbprint(jwk) { + // To produce a thumbprint we need to create a JSON string with only the required keys for + // the key type, with the keys sorted lexicographically and no white space. We then need + // run it through a SHA-256 and encode the result in url safe base64. + // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk + var keys; + if (jwk.kty === 'EC') { + keys = ['crv', 'x', 'y']; + } else if (jwk.kty === 'RSA') { + keys = ['e', 'n']; + } else { + return PromiseA.reject(new Error('invalid JWK key type ' + jwk.kty)); + } + keys.push('kty'); + keys.sort(); + + var missing = keys.filter(function (name) { return !jwk.hasOwnProperty(name); }); + if (missing.length > 0) { + return PromiseA.reject(new Error('JWK of type '+jwk.kty+' missing fields ' + missing)); + } + + // I'm not 100% sure this behavior is guaranteed by a real standard, but when we use an array + // as the replacer argument the keys are always in the order they appeared in the array. + var jwkStr = JSON.stringify(jwk, keys); + var hash = crypto.createHash('sha256').update(jwkStr).digest('base64'); + return PromiseA.resolve(makeB64UrlSafe(hash)); +} + +function create(app) { + var restful = {}; + restful.get = function (req, res) { + // The sub in params is the 3rd party PPID, but the keys are stored by the issuer PPID, so + // we need to look up the issuer PPID using the 3rd party PPID. + var promise = req.getSiteStore().then(function (store) { + if (req.params.kid === req.experienceId) { + return store.IssuerOauth3OrgPrivateKeys.get(req.experienceId); + } + + return store.IssuerOauth3OrgGrants.find({ azpSub: req.params.sub }).then(function (results) { + if (!results.length) { + throw new OpErr("unknown PPID '"+req.params.sub+"'"); + } + if (results.length > 1) { + // This should not ever happen since there is a check for PPID collisions when saving + // grants, but it's probably better to have this check anyway just incase something + // happens that isn't currently accounted for. + throw new OpErr('PPID collision - unable to safely retrieve keys'); + } + + return store.IssuerOauth3OrgJwks.get(results[0].sub+'/'+req.params.kid); + }); + }).then(function (jwk) { + if (!jwk) { + throw new OpErr("no keys stored with kid '"+req.params.kid+"' for PPID "+req.params.sub); + } + + // We need to sanitize the key to make sure we don't deliver any private keys fields if + // we were given a key we could use to sign tokens on behalf of the user. We also don't + // want to deliver the sub or any other PPIDs. + var whitelist = [ 'kty', 'alg', 'kid', 'use' ]; + if (jwk.kty === 'EC') { + whitelist = whitelist.concat([ 'crv', 'x', 'y' ]); + } else if (jwk.kty === 'RSA') { + whitelist = whitelist.concat([ 'e', 'n' ]); + } + + var result = {}; + whitelist.forEach(function (key) { + result[key] = jwk[key]; + }); + return result; + }); + + app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve JWK"); + }; + restful.saveNew = function (req, res) { + var jwk = req.body; + var promise = thumbprint(jwk).then(function (kid) { + if (jwk.kid && jwk.kid !== kid) { + throw new OpErr('provided kid "'+jwk.kid+'" does not match calculated "'+kid+'"'); + } + jwk.kid = kid; + jwk.sub = req.params.sub; + + return req.Store.upsert(jwk.sub+'/'+jwk.kid, jwk); + }).then(function () { + return { success: true }; + }); + + app.handlePromise(req, res, promise, "[issuer@oauth3.org] save JWK"); + }; + + return { + thumbprint: thumbprint, + restful: restful, + }; +} + +module.exports.create = create; diff --git a/rest.js b/rest.js index 7eabd6d..0bbc207 100644 --- a/rest.js +++ b/rest.js @@ -1,44 +1,8 @@ 'use strict'; -var PromiseA = require('bluebird'); -var crypto = require('crypto'); -var OpErr = PromiseA.OperationalError; - -function makeB64UrlSafe(b64) { - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, ''); -} - -function timespan(duration, max) { - var timestamp = Math.floor(Date.now() / 1000); - - if (!duration) { - return; - } - if (typeof duration === 'string') { - duration = Math.floor(require('ms')(duration) / 1000) || 0; - } - if (typeof duration !== 'number') { - return 0; - } - // Handle the case where the user gave us a timestamp instead of duration for the expiration. - // Also make the maximum explicitly defined expiration as one year. - if (duration > 31557600) { - if (duration > timestamp) { - return duration - timestamp; - } else { - return 31557600; - } - } - - if (max && timestamp+duration > max) { - return max - timestamp; - } - return duration; -} - module.exports.create = function (bigconf, deps, app) { - var Jwks = { restful: {} }; - var Grants = { restful: {} }; + var Jwks = require('./jwks').create(app); + var Grants = require('./grants').create(app); var Accounts = { restful: {} }; // This tablename is based on the tablename found in the objects in model.js. @@ -53,511 +17,14 @@ module.exports.create = function (bigconf, deps, app) { delete req.Store; next(); } - - function checkIsserToken(req, expectedSub) { - if (!req.oauth3 || !req.oauth3.verifyAsync) { - return PromiseA.reject(new OpErr("request requires a token for authorization")); - } - return req.oauth3.verifyAsync().then(function (token) { - // Now that we've confirmed the token is valid we also need to make sure the issuer, audience, - // and authorized party are all us, because no other app should be managing user identity. - if (token.iss !== req.experienceId || token.aud !== token.iss || token.azp !== token.iss) { - throw new OpErr("token does not allow access to requested resource"); - } - - var sub = token.sub || token.ppid || (token.acx && (token.acx.id || token.acx.appScopedId)); - if (!sub) { - if (!expectedSub || !Array.isArray(token.axs) || !token.axs.length) { - throw new OpErr("no account pairwise identifier"); - } - - var allowed = token.axs.some(function (acc) { - return expectedSub === (acc.id || acc.ppid || acc.appScopedId); - }); - if (!allowed) { - throw new OpErr("no account pairwise identifier matching '" + expectedSub + "'"); - } - sub = expectedSub; - } - - if (expectedSub && expectedSub !== sub) { - throw new OpErr("token does not allow access to resources for '"+expectedSub+"'"); - } - return sub; - }); - } function authorizeIssuer(req, res, next) { - var promise = checkIsserToken(req, req.params.sub).then(function () { + var promise = require('./common').checkIsserToken(req, req.params.sub).then(function () { next(); }); app.handleRejection(req, res, promise, '[issuer@oauth3.org] authorize req as issuer'); } - - Jwks.thumbprint = function (jwk) { - // To produce a thumbprint we need to create a JSON string with only the required keys for - // the key type, with the keys sorted lexicographically and no white space. We then need - // run it through a SHA-256 and encode the result in url safe base64. - // https://stackoverflow.com/questions/42588786/how-to-fingerprint-a-jwk - var keys; - if (jwk.kty === 'EC') { - keys = ['crv', 'x', 'y']; - } else if (jwk.kty === 'RSA') { - keys = ['e', 'n']; - } else { - return PromiseA.reject(new Error('invalid JWK key type ' + jwk.kty)); - } - keys.push('kty'); - keys.sort(); - - var missing = keys.filter(function (name) { return !jwk.hasOwnProperty(name); }); - if (missing.length > 0) { - return PromiseA.reject(new Error('JWK of type '+jwk.kty+' missing fields ' + missing)); - } - - // I'm not 100% sure this behavior is guaranteed by a real standard, but when we use an array - // as the replacer argument the keys are always in the order they appeared in the array. - var jwkStr = JSON.stringify(jwk, keys); - var hash = crypto.createHash('sha256').update(jwkStr).digest('base64'); - return PromiseA.resolve(makeB64UrlSafe(hash)); - }; - - Jwks.restful.get = function (req, res) { - // The sub in params is the 3rd party PPID, but the keys are stored by the issuer PPID, so - // we need to look up the issuer PPID using the 3rd party PPID. - var promise = req.getSiteStore().then(function (store) { - if (req.params.kid === req.experienceId) { - return store.IssuerOauth3OrgPrivateKeys.get(req.experienceId); - } - - return store.IssuerOauth3OrgGrants.find({ azpSub: req.params.sub }).then(function (results) { - if (!results.length) { - throw new OpErr("unknown PPID '"+req.params.sub+"'"); - } - if (results.length > 1) { - // This should not ever happen since there is a check for PPID collisions when saving - // grants, but it's probably better to have this check anyway just incase something - // happens that isn't currently accounted for. - throw new OpErr('PPID collision - unable to safely retrieve keys'); - } - - return store.IssuerOauth3OrgJwks.get(results[0].sub+'/'+req.params.kid); - }); - }).then(function (jwk) { - if (!jwk) { - throw new OpErr("no keys stored with kid '"+req.params.kid+"' for PPID "+req.params.sub); - } - - // We need to sanitize the key to make sure we don't deliver any private keys fields if - // we were given a key we could use to sign tokens on behalf of the user. We also don't - // want to deliver the sub or any other PPIDs. - var whitelist = [ 'kty', 'alg', 'kid', 'use' ]; - if (jwk.kty === 'EC') { - whitelist = whitelist.concat([ 'crv', 'x', 'y' ]); - } else if (jwk.kty === 'RSA') { - whitelist = whitelist.concat([ 'e', 'n' ]); - } - - var result = {}; - whitelist.forEach(function (key) { - result[key] = jwk[key]; - }); - return result; - }); - - app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve JWK"); - }; - Jwks.restful.saveNew = function (req, res) { - var jwk = req.body; - var promise = Jwks.thumbprint(jwk).then(function (kid) { - if (jwk.kid && jwk.kid !== kid) { - throw new OpErr('provided kid "'+jwk.kid+'" does not match calculated "'+kid+'"'); - } - jwk.kid = kid; - jwk.sub = req.params.sub; - - return req.Store.upsert(jwk.sub+'/'+jwk.kid, jwk); - }).then(function () { - return { success: true }; - }); - - app.handlePromise(req, res, promise, "[issuer@oauth3.org] save JWK"); - }; - - - Grants.trim = function (grant) { - return { - sub: grant.sub, - azp: grant.azp, - // azpSub: grant.azpSub, - scope: grant.scope, - updatedAt: parseInt(grant.updatedAt, 10), - }; - }; - - Grants.restful.getOne = function (req, res) { - var promise = req.Store.get(req.params.sub+'/'+req.params.azp).then(function (grant) { - if (!grant) { - throw new OpErr('no grants found'); - } - return Grants.trim(grant); - }); - - app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); - }; - Grants.restful.getAll = function (req, res) { - var promise = req.Store.find({ sub: req.params.sub }).then(function (results) { - return results.map(Grants.trim).sort(function (grantA, grantB) { - return (grantA.azp < grantB.azp) ? -1 : 1; - }); - }); - - app.handlePromise(req, res, promise, "[issuer@oauth3.org] retrieve grants"); - }; - Grants.restful.saveNew = function (req, res) { - var promise = PromiseA.resolve().then(function () { - if (typeof req.body.scope !== 'string' || typeof req.body.sub !== 'string') { - throw new OpErr("malformed request: 'sub' and 'scope' must be strings"); - } - return req.Store.find({ azpSub: req.body.sub }); - }).then(function (existing) { - if (existing.length) { - if (existing.length > 1) { - throw new OpErr("pre-existing PPID collision detected"); - } else if (existing[0].sub !== req.params.sub || existing[0].azp !== req.params.azp) { - throw new OpErr("PPID collision detected, cannot save authorized party's sub"); - } - } - - var grant = { - sub: req.params.sub, - azp: req.params.azp, - azpSub: req.body.sub, - scope: req.body.scope.split(/[+ ,]+/g).join(','), - }; - return req.Store.upsert(grant.sub+'/'+grant.azp, grant); - }).then(function () { - return {success: true}; - }); - - app.handlePromise(req, res, promise, '[issuer@oauth3.org] save grants'); - }; - - - Accounts.retrieveOtp = function (codeStore, codeId) { - return codeStore.get(codeId).then(function (code) { - if (!code) { - return null; - } - - var expires = (new Date(code.expires)).valueOf(); - if (!expires || Date.now() > expires) { - return codeStore.destroy(codeId).then(function () { - return null; - }); - } - - return code; - }); - }; - Accounts.validateOtp = function (codeStore, codeId, token) { - if (!codeId) { - return PromiseA.reject(new Error("Must provide authcode ID")); - } - if (!token) { - return PromiseA.reject(new Error("Must provide authcode code")); - } - return codeStore.get(codeId).then(function (code) { - if (!code) { - throw new OpErr('authcode specified does not exist or has expired'); - } - - return PromiseA.resolve().then(function () { - var attemptsLeft = 3 - (code.attempts && code.attempts.length || 0); - if (attemptsLeft <= 0) { - throw new OpErr('you have tried to authorize this code too many times'); - } - if (code.code !== token) { - throw new OpErr('you have entered the code incorrectly. '+attemptsLeft+' attempts remaining'); - } - // TODO: maybe impose a rate limit, although going fast doesn't help you break the - // system when you can only try 3 times total. - }).then(function () { - return codeStore.destroy(codeId).then(function () { - return code; - }); - }, function (err) { - code.attempts = code.attempts || []; - code.attempts.unshift(new Date()); - - return codeStore.upsert(codeId, code).then(function () { - return PromiseA.reject(err); - }, function () { - return PromiseA.reject(err); - }); - }); - }); - }; - - Accounts.getOrCreate = function (store, username) { - return store.IssuerOauth3OrgAccounts.get(username).then(function (account) { - if (account) { - return account; - } - - account = { - username: username, - accountId: makeB64UrlSafe(crypto.randomBytes(32).toString('base64')), - }; - return store.IssuerOauth3OrgAccounts.create(username, account).then(function () { - // TODO: put sort sort of email notification to the server managers? - return account; - }); - }); - }; - Accounts.getPrivKey = function (store, experienceId) { - return store.IssuerOauth3OrgPrivateKeys.get(experienceId).then(function (jwk) { - if (jwk) { - return jwk; - } - - var keyPair = require('elliptic').ec('p256').genKeyPair(); - jwk = { - kty: 'EC', - crv: 'P-256', - alg: 'ES256', - kid: experienceId, - x: makeB64UrlSafe(keyPair.getPublic().getX().toArrayLike(Buffer).toString('base64')), - y: makeB64UrlSafe(keyPair.getPublic().getY().toArrayLike(Buffer).toString('base64')), - d: makeB64UrlSafe(keyPair.getPrivate().toArrayLike(Buffer).toString('base64')), - }; - - return store.IssuerOauth3OrgPrivateKeys.upsert(experienceId, jwk).then(function () { - return jwk; - }); - }); - }; - - Accounts.restful.sendOtp = function (req, res) { - var params = req.body; - var promise = PromiseA.resolve().then(function () { - if (!params || !params.username) { - throw new OpErr("must provide the email address as 'username' in the body"); - } - if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { - throw new OpErr("only email one-time login codes are supported at this time"); - } - params.username_type = 'email'; - - return req.getSiteStore(); - }).then(function (store) { - var codeStore = store.IssuerOauth3OrgCodes; - var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); - codeId = makeB64UrlSafe(codeId); - - return Accounts.retrieveOtp(codeStore, codeId).then(function (code) { - if (code) { - return code; - } - - var token = ''; - while (!/^\d{4}-\d{4}-\d{4}$/.test(token)) { - // Most of the number we can generate this was start with 1 (and no matter what can't - // start with 0), so we don't use the very first digit. Also basically all of the - // numbers are too big to accurately store in JS floats, so we limit the trailing 0's. - token = (parseInt(crypto.randomBytes(8).toString('hex'), 16)).toString() - .replace(/0+$/, '0').replace(/\d(\d{4})(\d{4})(\d{4}).*/, '$1-$2-$3'); - } - code = { - id: codeId, - code: token, - expires: new Date(Date.now() + 20*60*1000), - }; - return codeStore.upsert(codeId, code).then(function (){ - return code; - }); - }); - }).then(function (code) { - var emailParams = { - to: params.username, - from: 'login@daplie.com', // opts.mailer.defaults.system - replyTo: 'hello@daplie.com', - subject: "Use " + code.code + " as your Login Code", // message.Subject - text: code.code + " is your Login Code." // message['stripped-html'] - }; - emailParams['h:Reply-To'] = emailParams.replyTo; - - return req.getSiteMailer().sendMailAsync(emailParams).then(function () { - return { - code_id: code.id, - expires: code.expires, - created: new Date(parseInt(code.createdAt, 10) || code.createdAt), - }; - }); - }); - - app.handlePromise(req, res, promise, '[issuer@oauth3.org] send one-time-password'); - }; - - Accounts.restful.createToken = function (req, res) { - var store; - var promise = req.getSiteStore().then(function (_store) { - store = _store; - if (!req.body || !req.body.grant_type) { - throw new OpErr("missing 'grant_type' from the body"); - } - - if (req.body.grant_type === 'password') { - return Accounts.restful.createToken.password(req); - } - if (req.body.grant_type === 'issuer_token') { - return Accounts.restful.createToken.issuerToken(req); - } - if (req.body.grant_type === 'refresh_token') { - return Accounts.restful.createToken.refreshToken(req); - } - - throw new OpErr("unknown or un-implemented grant_type '"+req.body.grant_type+"'"); - }).then(function (token_info) { - token_info.iss = req.experienceId; - if (!token_info.aud) { - throw new OpErr("missing required token field 'aud'"); - } - if (!token_info.azp) { - throw new OpErr("missing required token field 'azp'"); - } - - if (token_info.iss === token_info.azp) { - // We don't have normal grants for the issuer, so we don't need to look the - // azpSub or the grants up in the database. - token_info.azpSub = token_info.sub; - token_info.scope = ''; - return token_info; - } - - var search = {}; - ['sub', 'azp', 'azpSub'].forEach(function (key) { - if (token_info[key]) { - search[key] = token_info[key]; - } - }); - return store.IssuerOauth3OrgGrants.find(search).then(function (grants) { - if (!grants.length) { - throw new OpErr("'"+token_info.azp+"' not given any grants from '"+(token_info.sub || token_info.azpSub)+"'"); - } - if (grants.length > 1) { - throw new Error("unexpected resource collision: too many relevant grants"); - } - var grant = grants[0]; - Object.keys(grant).forEach(function (key) { - token_info[key] = grant[key]; - }); - return token_info; - }); - }).then(function (token_info) { - return Accounts.getPrivKey(store, req.experienceId).then(function (jwk) { - var pem = require('jwk-to-pem')(jwk, { private: true }); - var payload = { - // standard - iss: token_info.iss, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.1 - aud: token_info.aud, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.3 - azp: token_info.azp, - sub: token_info.azpSub, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.2 - // extended - scp: token_info.scope, - }; - var opts = { - algorithm: jwk.alg, - header: { - kid: jwk.kid - } - }; - var accessOpts = {}; - // We set `expiresIn` like this to make it possible to send `null` and `exp` to have - // no expiration while still having a default of 1 day. - if (req.body.hasOwnProperty('exp')) { - accessOpts.expiresIn = timespan(req.body.exp, token_info.exp); - } else { - accessOpts.expiresIn = timespan('1d', token_info.exp); - } - var refreshOpts = {}; - refreshOpts.expiresIn = timespan(req.body.refresh_exp, token_info.exp); - - var jwt = require('jsonwebtoken'); - var result = {}; - result.scope = token_info.scope; - result.access_token = jwt.sign(payload, pem, Object.assign(accessOpts, opts)); - if (req.body.refresh_token) { - if (token_info.refresh_token) { - result.refresh_token = token_info.refresh_token; - } else { - result.refresh_token = jwt.sign(payload, pem, Object.assign(refreshOpts, opts)); - } - } - return result; - }); - }); - - app.handlePromise(req, res, promise, '[issuer@oauth3.org] create tokens'); - }; - Accounts.restful.createToken.password = function (req) { - var params = req.body; - if (!params || !params.username) { - return PromiseA.reject(PromiseA.OperationalError("must provide the email address as 'username' in the body")); - } - if ((params.username_type && 'email' !== params.username_type) || !/@/.test(params.username)) { - return PromiseA.reject(PromiseA.OperationalError("only email one-time login codes are supported at this time")); - } - params.username_type = 'email'; - if (!params.password) { - return PromiseA.reject(new OpErr("request missing 'password'")); - } - - var codeId = crypto.createHash('sha256').update(params.username_type+':'+params.username).digest('base64'); - codeId = makeB64UrlSafe(codeId); - return req.getSiteStore().then(function (store) { - return Accounts.validateOtp(store.IssuerOauth3OrgCodes, codeId, params.password) - .then(function () { - return Accounts.getOrCreate(store, params.username); - }).then(function (account) { - return { - sub: account.accountId, - aud: req.params.aud || req.body.aud || req.experienceId, - azp: req.params.azp || req.body.azp || req.experienceId, - }; - }); - }); - }; - Accounts.restful.createToken.issuerToken = function (req) { - return checkIsserToken(req, req.params.sub || req.body.sub).then(function (sub) { - return { - sub: sub, - aud: req.params.aud || req.body.aud, - azp: req.params.azp || req.body.azp, - exp: req.oauth3.token.exp, - }; - }); - }; - Accounts.restful.createToken.refreshToken = function (req) { - return PromiseA.resolve().then(function () { - if (!req.body.refresh_token) { - throw new OpErr("missing refresh token"); - } - - return req.oauth3.verifyAsync(req.body.refresh_token).then(function (token) { - return { - sub: token.sub, - aud: token.aud, - azp: token.azp, - exp: token.exp, - refresh_token: req.body.refresh_token, - }; - }); - }); - }; - - app.get( '/jwks/:sub/:kid.json', Jwks.restful.get); app.get( '/jwks/:sub/:kid', Jwks.restful.get); // Everything but getting keys is only for the issuer