523 lines
20 KiB
JavaScript
523 lines
20 KiB
JavaScript
|
'use strict';
|
||
|
const util = require('util');
|
||
|
|
||
|
const debug = require('debug');
|
||
|
const debugTrace = debug('xssec:validators');
|
||
|
debugTrace.log = console.log.bind(console);
|
||
|
const debugError = debug('xssec:validators');
|
||
|
debugError.log = console.error.bind(console);
|
||
|
|
||
|
const url = require('url');
|
||
|
|
||
|
const TokenInfo = require('./tokeninfo');
|
||
|
const TokenExchanger = require('./tokenexchanger');
|
||
|
const ValidationResults = require("./validator/ValidationResults");
|
||
|
const X5tValidator = require("./validator/X5tValidator");
|
||
|
const JwksManager = require('./jwks/JwksManager');
|
||
|
const JwksReplica = require('./jwks/JwksReplica');
|
||
|
|
||
|
let jwksManager;
|
||
|
|
||
|
const DOT = ".";
|
||
|
|
||
|
function JwtAudienceValidator(clientId) {
|
||
|
var clientIds = [];
|
||
|
var foreignMode = false;
|
||
|
|
||
|
this.configureTrustedClientId = function (clientId) {
|
||
|
if (clientId) {
|
||
|
clientIds.push(clientId);
|
||
|
}
|
||
|
|
||
|
debugTrace("configured JwtAudienceValidator with clientId", clientId);
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
this.validateToken = function (audiencesFromToken, scopesFromToken, cid) {
|
||
|
foreignMode = false;
|
||
|
var allowedAudiences = extractAudiencesFromToken(audiencesFromToken, scopesFromToken || [], cid);
|
||
|
if (validateSameClientId(cid) === true || validateAudienceOfXsuaaBrokerClone(allowedAudiences) === true || validateDefault(allowedAudiences) === true) {
|
||
|
return ValidationResults.createValid();
|
||
|
}
|
||
|
|
||
|
return ValidationResults.createInvalid("Jwt token with audience: " + util.inspect(allowedAudiences) + " is not issued for these clientIds: " + util.inspect(clientIds) + ".");
|
||
|
}
|
||
|
|
||
|
this.isForeignMode = function () {
|
||
|
return foreignMode;
|
||
|
}
|
||
|
|
||
|
function validateSameClientId(cidFromToken) {
|
||
|
if (!cidFromToken || !clientId) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
return cidFromToken.trim() === clientId.trim();
|
||
|
}
|
||
|
|
||
|
//iterate over all configured clientIds and return true of the cb returns true
|
||
|
function forEachClientId(cb) {
|
||
|
for (var i = 0; i < clientIds.length; ++i) {
|
||
|
if (cb(clientIds[i]) === true) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
function validateDefault(allowedAudiences) {
|
||
|
return forEachClientId(function (configuredClientId) {
|
||
|
if (allowedAudiences.includes(configuredClientId)) {
|
||
|
return true;
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
function validateAudienceOfXsuaaBrokerClone(allowedAudiences) {
|
||
|
var ret = forEachClientId(function (configuredClientId) {
|
||
|
if (configuredClientId.includes("!b")) { //isBrokerClientId
|
||
|
for (var i = 0; i < allowedAudiences.length; ++i) {
|
||
|
var audience = allowedAudiences[i];
|
||
|
if (audience.endsWith("|" + configuredClientId)) {
|
||
|
return true;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
if (ret === null) {
|
||
|
foreignMode = true;
|
||
|
}
|
||
|
return ret;
|
||
|
}
|
||
|
|
||
|
this.getListOfAudiencesFromToken = function (aud, scopes, cid) {
|
||
|
return extractAudiencesFromToken(aud || [], scopes || [], cid);
|
||
|
}
|
||
|
|
||
|
function extractAudiencesFromToken(aud, scopes, cid) {
|
||
|
var audiences = [];
|
||
|
var tokenAudiences = aud || [];
|
||
|
|
||
|
for (var i = 0; i < tokenAudiences.length; ++i) {
|
||
|
var audience = tokenAudiences[i];
|
||
|
if (audience.indexOf(DOT) > -1) {
|
||
|
// CF UAA derives the audiences from the scopes.
|
||
|
// In case the scopes contains namespaces, these needs to be removed.
|
||
|
var aud = audience.substring(0, audience.indexOf(DOT)).trim();
|
||
|
if (aud && !audiences.includes(aud)) {
|
||
|
audiences.push(aud);
|
||
|
}
|
||
|
} else {
|
||
|
audiences.push(audience);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (audiences.length == 0) {
|
||
|
for (var i = 0; i < scopes.length; ++i) {
|
||
|
var scope = scopes[i];
|
||
|
if (scope.indexOf(DOT) > -1) {
|
||
|
var aud = scope.substring(0, scope.indexOf(DOT)).trim();
|
||
|
if (aud && !audiences.includes(aud)) {
|
||
|
audiences.push(aud);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (cid && audiences.indexOf(cid) === -1) {
|
||
|
audiences.push(cid);
|
||
|
}
|
||
|
|
||
|
return audiences;
|
||
|
}
|
||
|
|
||
|
//allow an empty constructor
|
||
|
if (clientId) {
|
||
|
this.configureTrustedClientId(clientId);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function buildJwksManager(config) {
|
||
|
if(!config) {
|
||
|
return new JwksManager();
|
||
|
}
|
||
|
|
||
|
let jwksExpirationTime = config.expirationTime || JwksReplica.DEFAULT_EXPIRATION_TIME;
|
||
|
let jwksRefreshPeriod = config.refreshPeriod || JwksReplica.DEFAULT_REFRESH_PERIOD;
|
||
|
|
||
|
return new JwksManager().withExpirationTime(jwksExpirationTime).withRefreshPeriod(jwksRefreshPeriod);
|
||
|
}
|
||
|
|
||
|
function JwtTokenValidatorIAS(configArray, serviceCredentials, attributes) {
|
||
|
if(!jwksManager) {
|
||
|
jwksManager = buildJwksManager(serviceCredentials.jwksCache);
|
||
|
}
|
||
|
|
||
|
this.isForeignMode = function () {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
this.validateToken = function (accessToken, cb) {
|
||
|
function returnError(code, errorString) {
|
||
|
debugError('\n' + errorString);
|
||
|
var error = new Error(errorString);
|
||
|
error.statuscode = code;
|
||
|
return cb(error);
|
||
|
}
|
||
|
|
||
|
//make sure we have at least an array of 1 domain
|
||
|
const trustedDomains = Array.isArray(serviceCredentials.domains) ? serviceCredentials.domains : [serviceCredentials.domain];
|
||
|
const token = new TokenInfo(accessToken);
|
||
|
|
||
|
//Issuer validation
|
||
|
const issuer = token.getIssuer();
|
||
|
let issuerDomain;
|
||
|
try {
|
||
|
issuerDomain = this.getIssuerDomain(issuer, trustedDomains);
|
||
|
} catch (e) {
|
||
|
return returnError(401, `Issuer validation failed for issuer ${issuer}, message=${e}`);
|
||
|
}
|
||
|
|
||
|
// (optional) x5t validation
|
||
|
if (attributes["x5tValidation"]) {
|
||
|
if (!attributes.x509Certificate) {
|
||
|
return returnError(401, "x5t validation failed (no client certificate found in request header).");
|
||
|
} else {
|
||
|
let x5tValidationResult = X5tValidator.validateToken(token, attributes.x509Certificate);
|
||
|
if (!x5tValidationResult.isValid()) {
|
||
|
return returnError(401, x5tValidationResult.getErrorDescription());
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const verificationKeySupplier = async (header, callback) => {
|
||
|
let err, jwk;
|
||
|
try {
|
||
|
const jwks = await jwksManager.getIdentityJwks(issuerDomain, serviceCredentials, token, attributes);
|
||
|
jwk = await jwks.get(header.kid);
|
||
|
} catch(e) {
|
||
|
err = e;
|
||
|
}
|
||
|
|
||
|
callback(err, jwk ? jwk.value : null);
|
||
|
}
|
||
|
|
||
|
return token.verify(verificationKeySupplier, function(err, token) {
|
||
|
if (err) {
|
||
|
debugError('\n' + err.message);
|
||
|
err.statuscode = 401;
|
||
|
return cb(err);
|
||
|
}
|
||
|
|
||
|
//Audience validation
|
||
|
let audienceValidator = new JwtAudienceValidator(serviceCredentials.clientid);
|
||
|
for (var i = 1; i < configArray.length; ++i) {
|
||
|
if (configArray[i] && configArray[i].clientid) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[i].clientid);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let valid_result = audienceValidator.validateToken(token.getAudiencesArray());
|
||
|
if (!valid_result.isValid()) {
|
||
|
return returnError(401, valid_result.getErrorDescription());
|
||
|
}
|
||
|
|
||
|
cb(null, token);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns the issuer domain based on the issuer of the token but validates it against a list of trusted domains.
|
||
|
* @param {string} issuer issuer from token
|
||
|
* @param {Array<string>} trustedDomains a list of trusted domains
|
||
|
* @returns domain of issuer if is either a trusted domain or a subdomain of a trusted domain
|
||
|
* @throws Error if issuer is empty, not trusted or not a valid URL
|
||
|
*/
|
||
|
this.getIssuerDomain = function (issuer, trustedDomains = []) {
|
||
|
if(!issuer) {
|
||
|
throw new Error("No issuer found.");
|
||
|
}
|
||
|
|
||
|
const httpsScheme = "https://";
|
||
|
const issuerUrl = issuer.startsWith(httpsScheme) ? issuer : `${httpsScheme}${issuer}`;
|
||
|
try {
|
||
|
new URL(issuerUrl);
|
||
|
} catch(e) {
|
||
|
throw new Error("Issuer was not a valid URL suitable for https.", e);
|
||
|
}
|
||
|
|
||
|
const issuerDomain = issuerUrl.substring(httpsScheme.length);
|
||
|
for(let d of trustedDomains) {
|
||
|
const validSubdomainPattern = `^[a-zA-Z0-9-]{1,63}\\.${escapeStringForRegex(d)}$`; // a string that ends with .<trustedDomain> and contains 1-63 letters, digits or '-' before that for the subdomain
|
||
|
if(issuerDomain === d || issuerDomain.match(new RegExp(validSubdomainPattern))) {
|
||
|
return issuerDomain;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
throw new Error("Issuer domain was not a trusted domain or a subdomain of a trusted domain.")
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Escapes Regex special characters in the given string, so that the string can be used for a literal match inside a Regex.
|
||
|
* Regex.escape is only a proposal at the time of writing.
|
||
|
* The source of this code is https://github.com/tc39/proposal-regex-escaping/blob/main/polyfill.js
|
||
|
*/
|
||
|
function escapeStringForRegex(s) {
|
||
|
return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/* Adds missing line breaks to malformed PEM keys.
|
||
|
* For backward-compatibility, a specific kind of malformed PEM needs to be supported that is lacking line breaks around the header and footer.
|
||
|
* This kind of PEM input can occur, for example, in old service bindings of XSA and is not always fixable by consumers of this library.
|
||
|
*/
|
||
|
function cleanUpPemKey(pem) {
|
||
|
if(!pem) {
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
const header = "-----BEGIN PUBLIC KEY-----";
|
||
|
if(!pem.includes(`${header}\n`)) {
|
||
|
pem = pem.replace(header, `${header}\n`);
|
||
|
}
|
||
|
|
||
|
const footer = "-----END PUBLIC KEY-----";
|
||
|
if(!pem.includes(`\n${footer}`)) {
|
||
|
pem = pem.replace(footer, `\n${footer}`);
|
||
|
}
|
||
|
|
||
|
return pem;
|
||
|
}
|
||
|
|
||
|
function JwtTokenValidatorXSUAA(configArray, serviceCredentials, attributes) {
|
||
|
if(!jwksManager) {
|
||
|
jwksManager = buildJwksManager(serviceCredentials.jwksCache);
|
||
|
}
|
||
|
var foreignMode = false;
|
||
|
|
||
|
this.isForeignMode = function () {
|
||
|
return foreignMode;
|
||
|
}
|
||
|
|
||
|
//prepare JWT validators
|
||
|
this.validateToken = function (accessToken, cb) {
|
||
|
function returnError(code, errorString) {
|
||
|
debugError('\n' + errorString);
|
||
|
var error = new Error(errorString);
|
||
|
error.statuscode = code;
|
||
|
return cb(error);
|
||
|
}
|
||
|
|
||
|
let tokeninfo = new TokenExchanger(serviceCredentials);
|
||
|
|
||
|
return tokeninfo.prepareToken(accessToken,
|
||
|
function (err, token) {
|
||
|
if (err) {
|
||
|
debugError('\n' + err.message);
|
||
|
err.statuscode = 401;
|
||
|
return cb(err);
|
||
|
}
|
||
|
|
||
|
let kidNotInJwksErr = null;
|
||
|
const verificationKeySupplier = async (header, callback) => {
|
||
|
let keyFromConfig = cleanUpPemKey(serviceCredentials.verificationkey);
|
||
|
|
||
|
if(!header.jku || !header.kid || header.kid == 'legacy-token-key') {
|
||
|
debugTrace("Token header contained no JKU or KID or the KID was 'legacy-token-key'. Using verification key from service configuration.");
|
||
|
return callback(null, keyFromConfig);
|
||
|
}
|
||
|
|
||
|
let jwk;
|
||
|
try {
|
||
|
const jwks = jwksManager.getXsuaaJwks(serviceCredentials.uaadomain || serviceCredentials.url, token.getZoneId(), attributes);
|
||
|
jwk = await jwks.get(header.kid);
|
||
|
} catch(e) {
|
||
|
if(e.kidMissingInJwks) {
|
||
|
// try validation with keyFromConfig but remember KID was not in JWKS for error logging
|
||
|
kidNotInJwksErr = e;
|
||
|
jwk = keyFromConfig;
|
||
|
} else {
|
||
|
return callback(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return callback(null, jwk ? cleanUpPemKey(jwk.value) : keyFromConfig);
|
||
|
}
|
||
|
|
||
|
return token.verify(verificationKeySupplier,
|
||
|
function (err, token) {
|
||
|
if (err) {
|
||
|
if(kidNotInJwksErr) {
|
||
|
// verification with keyFromConfig as fallback failed after key with KID was not in JWKS
|
||
|
debugError(kidNotInJwksErr);
|
||
|
return cb(kidNotInJwksErr, token);
|
||
|
}
|
||
|
|
||
|
debugError(err.statuscode);
|
||
|
debugError(err.message);
|
||
|
debugError(err.stack);
|
||
|
return cb(err, token);
|
||
|
}
|
||
|
|
||
|
var decodedToken = token.getPayload();
|
||
|
|
||
|
if (!token.getClientId()) {
|
||
|
return returnError(400, 'Client Id not contained in access token. Giving up!');
|
||
|
}
|
||
|
|
||
|
if (!decodedToken.zid) {
|
||
|
return returnError(400, 'Identity Zone not contained in access token. Giving up!');
|
||
|
}
|
||
|
|
||
|
var audienceValidator = new JwtAudienceValidator(configArray[0].clientid);
|
||
|
if (configArray[0].xsappname) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[0].xsappname);
|
||
|
}
|
||
|
|
||
|
for (var i = 1; i < configArray.length; ++i) {
|
||
|
if (configArray[i]) {
|
||
|
if (configArray[i].clientid) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[i].clientid);
|
||
|
}
|
||
|
if (configArray[i].xsappname) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[i].xsappname);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var valid_result = audienceValidator.validateToken(token.getAudiencesArray(), decodedToken.scope, decodedToken.cid);
|
||
|
if (!valid_result.isValid()) {
|
||
|
return returnError(401, valid_result.getErrorDescription());
|
||
|
}
|
||
|
|
||
|
if (configArray[0].clientid !== decodedToken.cid) {
|
||
|
foreignMode = audienceValidator.isForeignMode();
|
||
|
}
|
||
|
|
||
|
cb(null, token);
|
||
|
}
|
||
|
);
|
||
|
})
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function JwtTokenValidatorUAA(configArray, serviceCredentials, attributes) {
|
||
|
if(!jwksManager) {
|
||
|
jwksManager = buildJwksManager(serviceCredentials.jwksCache);
|
||
|
}
|
||
|
var foreignMode = false;
|
||
|
|
||
|
this.isForeignMode = function () {
|
||
|
return foreignMode;
|
||
|
}
|
||
|
|
||
|
//prepare JWT validators
|
||
|
this.validateToken = function (accessToken, cb) {
|
||
|
function returnError(code, errorString) {
|
||
|
debugError('\n' + errorString);
|
||
|
var error = new Error(errorString);
|
||
|
error.statuscode = code;
|
||
|
return cb(error);
|
||
|
}
|
||
|
|
||
|
let tokeninfo = new TokenExchanger(serviceCredentials, true);
|
||
|
|
||
|
return tokeninfo.prepareToken(accessToken,
|
||
|
function (err, token) {
|
||
|
if (err) {
|
||
|
debugError('\n' + err.message);
|
||
|
err.statuscode = 401;
|
||
|
return cb(err);
|
||
|
}
|
||
|
|
||
|
let kidNotInJwksErr = null;
|
||
|
const verificationKeySupplier = async (header, callback) => {
|
||
|
let keyFromConfig = cleanUpPemKey(serviceCredentials.verificationkey);
|
||
|
|
||
|
if(!header.jku || !header.kid || header.kid == 'legacy-token-key') {
|
||
|
debugTrace("Token header contained no JKU or KID or the KID was 'legacy-token-key'. Using verification key from service configuration.");
|
||
|
return callback(null, keyFromConfig);
|
||
|
}
|
||
|
|
||
|
let jwk;
|
||
|
try {
|
||
|
const jwks = jwksManager.getXsuaaJwks(serviceCredentials.uaadomain || serviceCredentials.url, token.getZoneId(), attributes);
|
||
|
jwk = await jwks.get(header.kid);
|
||
|
} catch(e) {
|
||
|
if(e.kidMissingInJwks) {
|
||
|
// try validation with keyFromConfig but remember KID was not in JWKS for error logging
|
||
|
kidNotInJwksErr = e;
|
||
|
jwk = keyFromConfig;
|
||
|
} else {
|
||
|
return callback(e);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return callback(null, jwk ? cleanUpPemKey(jwk.value) : keyFromConfig);
|
||
|
}
|
||
|
|
||
|
return token.verify(verificationKeySupplier,
|
||
|
function (err, token) {
|
||
|
if (err) {
|
||
|
if(kidNotInJwksErr) {
|
||
|
// verification with keyFromConfig as fallback failed after key with KID was not in JWKS
|
||
|
debugError(kidNotInJwksErr);
|
||
|
return cb(kidNotInJwksErr, token);
|
||
|
}
|
||
|
|
||
|
debugError(err.statuscode);
|
||
|
debugError(err.message);
|
||
|
debugError(err.stack);
|
||
|
return cb(err, token);
|
||
|
}
|
||
|
|
||
|
var decodedToken = token.getPayload();
|
||
|
|
||
|
if (!token.getClientId()) {
|
||
|
return returnError(400, 'Client Id not contained in access token. Giving up!');
|
||
|
}
|
||
|
|
||
|
if (!decodedToken.zid) {
|
||
|
return returnError(400, 'Identity Zone not contained in access token. Giving up!');
|
||
|
}
|
||
|
|
||
|
var audienceValidator = new JwtAudienceValidator(configArray[0].clientid);
|
||
|
if (configArray[0].xsappname) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[0].xsappname);
|
||
|
}
|
||
|
|
||
|
for (var i = 1; i < configArray.length; ++i) {
|
||
|
if (configArray[i]) {
|
||
|
if (configArray[i].clientid) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[i].clientid);
|
||
|
}
|
||
|
if (configArray[i].xsappname) {
|
||
|
audienceValidator.configureTrustedClientId(configArray[i].xsappname);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var valid_result = audienceValidator.validateToken(token.getAudiencesArray(), decodedToken.scope, decodedToken.cid);
|
||
|
if (!valid_result.isValid()) {
|
||
|
return returnError(401, valid_result.getErrorDescription());
|
||
|
}
|
||
|
|
||
|
if (configArray[0].clientid !== decodedToken.cid) {
|
||
|
foreignMode = audienceValidator.isForeignMode();
|
||
|
}
|
||
|
|
||
|
cb(null, token);
|
||
|
}
|
||
|
);
|
||
|
})
|
||
|
};
|
||
|
}
|
||
|
|
||
|
module.exports = {
|
||
|
JwtTokenValidatorIAS: JwtTokenValidatorIAS,
|
||
|
JwtTokenValidatorUAA: JwtTokenValidatorUAA,
|
||
|
JwtTokenValidatorXSUAA: JwtTokenValidatorXSUAA,
|
||
|
JwtAudienceValidator: JwtAudienceValidator
|
||
|
};
|