'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} 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 . 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 };