this is to prevent walnut from logging the entire stack trace for errors that aren't really bugs that need to be traced down
412 lines
15 KiB
JavaScript
412 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
var PromiseA = require('bluebird');
|
|
var crypto = require('crypto');
|
|
var OpErr = PromiseA.OperationalError;
|
|
|
|
function makeB64UrlSafe(b64) {
|
|
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/, '');
|
|
}
|
|
|
|
module.exports.create = function (bigconf, deps, app) {
|
|
var Jwks = { restful: {} };
|
|
var Grants = { restful: {} };
|
|
var Tokens = { restful: {} };
|
|
|
|
// This tablename is based on the tablename found in the objects in model.js.
|
|
// Instead of the snake_case the name with be UpperCammelCase, converted by masterquest-sqlite3.
|
|
function attachSiteStore(tablename, req, res, next) {
|
|
return req.getSiteStore().then(function (store) {
|
|
req.Store = store[tablename];
|
|
next();
|
|
});
|
|
}
|
|
function detachSiteStore(req, res, next) {
|
|
delete req.Store;
|
|
next();
|
|
}
|
|
|
|
|
|
function authorizeIssuer(req, res, next) {
|
|
var promise = PromiseA.resolve().then(function () {
|
|
// It might seem unnecessary to wrap a promise in another promise, but this way it will
|
|
// catch the error thrown when a token isn't provided and verifyAsync isn't a function.
|
|
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 (!Array.isArray(token.axs) || !token.axs.length) {
|
|
throw new OpErr("no account pairwise identifier");
|
|
}
|
|
|
|
var allowed = token.axs.some(function (acc) {
|
|
return req.params.sub === (acc.id || acc.ppid || acc.appScopedId);
|
|
});
|
|
if (!allowed) {
|
|
throw new OpErr("no account pairwise identifier matching '" + req.params.sub + "'");
|
|
}
|
|
sub = req.params.sub;
|
|
}
|
|
|
|
if (req.params.sub !== sub) {
|
|
throw new OpErr("token does not allow access to resources for '"+req.params.sub+"'");
|
|
}
|
|
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');
|
|
};
|
|
|
|
Tokens.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;
|
|
});
|
|
};
|
|
Tokens.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);
|
|
});
|
|
});
|
|
});
|
|
};
|
|
Tokens.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;
|
|
});
|
|
});
|
|
};
|
|
|
|
Tokens.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 Tokens.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 {
|
|
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');
|
|
};
|
|
Tokens.restful.create = function (req, res) {
|
|
var store;
|
|
var promise = req.getSiteStore().then(function (_store) {
|
|
store = _store;
|
|
return store.IssuerOauth3OrgGrants.get(req.params.sub+'/'+req.params.azp);
|
|
}).then(function (grant) {
|
|
if (!grant) {
|
|
throw new OpErr("'"+req.params.azp+"' not given any grants from '"+req.params.sub+"'");
|
|
}
|
|
return Tokens.getPrivKey(store, req.experienceId).then(function (jwk) {
|
|
var pem = require('jwk-to-pem')(jwk, { private: true });
|
|
var payload = {
|
|
// standard
|
|
iss: req.experienceId, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.1
|
|
aud: req.params.aud, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.3
|
|
azp: grant.azp,
|
|
sub: grant.azpSub, // https://tools.ietf.org/html/draft-ietf-oauth-json-web-token-32#section-4.1.2
|
|
// extended
|
|
scp: grant.scope,
|
|
};
|
|
var opts = {
|
|
algorithm: jwk.alg,
|
|
header: {
|
|
kid: jwk.kid
|
|
}
|
|
};
|
|
|
|
var jwt = require('jsonwebtoken');
|
|
var result = {};
|
|
result.scope = grant.scope;
|
|
result.access_token = jwt.sign(payload, pem, Object.assign({expiresIn: req.body.exp || '1d'}, opts));
|
|
if (req.body.refresh_token) {
|
|
result.refresh_token = jwt.sign(payload, pem, Object.assign({expiresIn: req.body.refresh_exp}, opts));
|
|
}
|
|
return result;
|
|
});
|
|
});
|
|
|
|
app.handlePromise(req, res, promise, '[issuer@oauth3.org] create tokens');
|
|
};
|
|
|
|
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
|
|
app.use( '/jwks/:sub', authorizeIssuer, attachSiteStore.bind(null, 'IssuerOauth3OrgJwks'));
|
|
app.post( '/jwks/:sub', Jwks.restful.saveNew);
|
|
|
|
// Everything regarding grants is only for the issuer
|
|
app.use( '/grants/:sub', authorizeIssuer, attachSiteStore.bind(null, 'IssuerOauth3OrgGrants'));
|
|
app.get( '/grants/:sub', Grants.restful.getAll);
|
|
app.get( '/grants/:sub/:azp', Grants.restful.getOne);
|
|
app.post( '/grants/:sub/:azp', Grants.restful.saveNew);
|
|
|
|
app.post( '/access_token/send_otp', Tokens.restful.sendOtp);
|
|
app.use( '/access_token/:sub', authorizeIssuer);
|
|
app.post( '/access_token/:sub/:aud/:azp', Tokens.restful.create);
|
|
|
|
app.use(detachSiteStore);
|
|
};
|