@sap/xssec
Version:
XS Advanced Container Security API for node.js
525 lines (452 loc) • 23 kB
JavaScript
'use strict';
/**
* @typedef {import("crypto").X509Certificate} X509Certificate
*/
const Service = require('./Service');
const SecurityContext = require('../context/SecurityContext');
const IdentityServiceSecurityContext = require('../context/IdentityServiceSecurityContext');
const IdentityServiceToken = require('../token/IdentityServiceToken');
const Jwks = require('../jwks/Jwks');
const LRUCache = require('../cache/LRUCache');
const { MissingClientCertificateError, MissingIssuerError, InvalidIssuerError, UntrustedIssuerError, X5tError, ConfigurationError, ResponseError, WrongAudienceError } = require('../error');
const { parsePemCertificate } = require('../util/certs');
const createCacheKey = require('../cache/createCacheKey');
const { escapeStringForRegex } = require('../util/util');
const { jsonRequest } = require('../util/jsonRequest');
const { APP_TID_HEADER, AZP_HEADER, CLIENT_CERTIFICATE_HEADER, CLIENTID_HEADER, HTTPS_SCHEME, SERVICE_PLAN_HEADER, X5T_CNF_CLAIM } = require('../util/constants');
const Token = require("../token/Token");
const XsuaaLegacyExtension = require("../context/XsuaaLegacyExtension");
/**
* @typedef {import('../util/Types').ServiceCredentials} ServiceCredentials
* @typedef {import('../util/Types').IdentityServiceCredentials} IdentityServiceCredentials
* @typedef {import('../util/Types').ServiceConfig} ServiceConfig
* @typedef {import('../util/Types').IdentityServiceConfig} IdentityServiceConfig *
* @typedef {import('../util/Types').SecurityContextConfig} SecurityContextConfig *
* @typedef {import('../util/Types').TokenFetchOptions} TokenFetchOptions
* @typedef {import('../util/Types').IdentityServiceTokenFetchOptions} IdentityServiceTokenFetchOptions
* @typedef {import('../util/Types').TokenFetchResponse} TokenFetchResponse
* @typedef {import('../util/Types').IdTokenFetchResponse} IdTokenFetchResponse
* @typedef {import('../util/Types').RefreshableTokenFetchResponse} RefreshableTokenFetchResponse
*/
/**
* This {@link Service} class is constructed from SAP Identity Service credentials to provide an API with selected functionality against that service instance, e.g. token validation and token fetches.
*/
class IdentityService extends Service {
/** @type {import("../util/Types").CacheConfig} */
static DEFAULT_ID_TOKEN_CACHE_CONFIG = {
enabled: true,
size: 100
};
/**
* A string->jwt cache used for caching ID tokens for {@link _getIdToken}. Can be either an external cache implementation or a per-instance LRUCache.
* @type {import("../util/Types").Cache}
*/
#idTokenCache;
/**
* @param {ServiceCredentials & IdentityServiceCredentials} credentials
* @param {ServiceConfig & IdentityServiceConfig} [serviceConfiguration={}]
*/
constructor(credentials, serviceConfiguration) {
super(credentials, serviceConfiguration);
this.config = IdentityService.buildServiceConfiguration(this.config);
if (this.config.xsuaaLegacyExtension) {
const extensionConfig = this.config.xsuaaLegacyExtension === true ? {} : this.config.xsuaaLegacyExtension;
this.contextExtensions.push(new XsuaaLegacyExtension(extensionConfig));
}
}
/**
* @override
* @param {String|IdentityServiceToken} token token as JWT or IdentityServiceToken object
* @param {SecurityContextConfig} contextConfig
* @returns {Promise<IdentityServiceSecurityContext>}
*/
async createSecurityContext(token, contextConfig = {}) {
if (typeof token === "string") {
token = new IdentityServiceToken(token);
} else if (token instanceof Token && !(token instanceof IdentityServiceToken)) {
token = new IdentityServiceToken(token.jwt, { header: token.header, payload: token.payload });
}
SecurityContext.buildContextConfig(contextConfig);
if (contextConfig.skipValidation !== true && this.config.validation.enabled !== false) {
if (this.#proofTokenCheckRequired(token) || this.hasX5tEnabled()) {
if (contextConfig.clientCertificatePem == null) {
throw new MissingClientCertificateError();
} else {
contextConfig.clientCertificate = parsePemCertificate(contextConfig.clientCertificatePem);
}
}
await this.validateToken(token, contextConfig);
}
let ctx = new IdentityServiceSecurityContext(this, token, contextConfig);
for (let extension of this.contextExtensions) {
ctx = await extension.extendSecurityContext(ctx) ?? ctx;
}
return ctx;
}
/**
* @override
* @param {IdentityServiceToken} token
* @param {SecurityContextConfig} contextConfig
*/
async validateToken(token, contextConfig) {
if (this.hasX5tEnabled()) {
IdentityService.validateTokenOwnership(token, contextConfig.clientCertificate);
}
// expiration time, audience, signature etc.
await super.validateToken(token, contextConfig);
}
/**
* Retrieves the JWKS (JSON Web Key Set) for the given token and context configuration.
*
* @param {string} token - The token for which to retrieve the JWKS.
* @param {SecurityContextConfig} contextConfig - The context configuration object.
* @returns {Promise<Jwks>} - A promise that resolves to the JWKS (JSON Web Key Set) object.
* @throws {WrongAudienceError} - if the online validation in the JWKS endpoint of the Identity Service instance responds with a 400 status code, indicating that the token should not be accepted by this service.
* @throws {Error} - If an error occurs while retrieving the JWKS.
*/
async getJwks(token, contextConfig) {
this.validateCredentials("fetch JWKS", "clientid", "url");
// throws for malicious issuers
const issuerUrl = IdentityService.getSafeUrlFromTokenIssuer(token, this.credentials.domains);
const jwksParams = {
clientid: this.credentials.clientid,
app_tid: token.appTid,
azp: token.azp
}
const keyParts = { url: issuerUrl, ...jwksParams };
const proofTokenCheck = this.#proofTokenCheckRequired(token);
if (proofTokenCheck) {
keyParts.certSub = contextConfig.clientCertificate?.subject;
jwksParams.clientCertificatePem = contextConfig.clientCertificatePem?.replaceAll("\\n", "").replaceAll("\n", "");
}
const cacheKey = createCacheKey(keyParts);
const buildJwksRequest = () => {
// fetch JWKS either from this service if it has issued the token, or if not, from the trusted issuer service using this service's client id
const issuerService = issuerUrl === this.credentials.url ? this : this.#getSubscriberIdentityService(token);
const extractHeaders = proofTokenCheck ? "headers" : null;
return async (correlationId) => {
const jwksResponse = await issuerService.fetchJwks(jwksParams, { correlationId, extractHeaders });
return {
jwks: new Jwks(jwksResponse.keys),
servicePlans: proofTokenCheck ? jwksResponse.headers.get(SERVICE_PLAN_HEADER)?.split(",").map(plan => plan.replaceAll("\"", "")) : null
};
}
}
let cachedResponse;
try {
cachedResponse = await this.jwksCache.getOrRequest(cacheKey, buildJwksRequest, { correlationId: contextConfig.correlationId });
} catch (error) {
if (error instanceof ResponseError && error.responseCode === 400 && error.request.name === `${this.constructor.name}.fetchJwks`) {
/**
* Online validation in JWKS endpoint may have negative result for the provided header / query parameters.
* In this case, the response will have status 400 and should result in a WrongAudienceError instead of a (subclass of) NetworkError.
*/
throw new WrongAudienceError(token, this, `The online validation in the JWKS endpoint of the Identity Service instance responded with 400: '${error.responseText}'. This indicates that the token should not be accepted by this service.`);
}
throw error;
}
const { jwks, servicePlans } = cachedResponse;
if (proofTokenCheck) {
contextConfig.servicePlans = servicePlans;
}
return jwks;
}
async fetchJwks({ clientid, app_tid, azp, clientCertificatePem }, { correlationId, extractHeaders }) {
const openIDConfiguration = await this.getOpenIDConfiguration({ correlationId });
const jwksUrl = openIDConfiguration.jwks_uri;
const request = this.buildRequest({
method: 'GET',
headers: {
[CLIENTID_HEADER]: clientid
}
});
if (app_tid != null) {
request.headers[APP_TID_HEADER] = app_tid;
}
if (azp != null) {
request.headers[AZP_HEADER] = azp;
}
if (clientCertificatePem != null) {
request.headers[CLIENT_CERTIFICATE_HEADER] = clientCertificatePem
}
return jsonRequest(jwksUrl, request, { requestName: `${this.constructor.name}.fetchJwks`, correlationId, extractHeaders });
}
// Re-declare JSDoc for token fetches with detailed options and return object properties
/**
* Fetches a token from this service with this service's client credentials.
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>}
*/
async fetchClientCredentialsToken(options = {}) {
return super.fetchClientCredentialsToken(options);
}
/**
* Fetches a user token from this service with the given username and password.
* @param {String} username
* @param {String} password
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options
* @returns {Promise<TokenFetchResponse & IdTokenFetchResponse & RefreshableTokenFetchResponse>}
*/
async fetchPasswordToken(username, password, options = {}) {
return super.fetchPasswordToken(username, password, options);
}
/**
* Fetches a JWT bearer token from this service with the given user token as assertion.
* @param {String} assertion JWT bearer token used as assertion
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options
* @returns {Promise<TokenFetchResponse & IdTokenFetchResponse & RefreshableTokenFetchResponse>}
*/
async fetchJwtBearerToken(assertion, options = {}) {
let app_tid = options.app_tid;
if(app_tid === undefined) {
const token = new IdentityServiceToken(assertion);
app_tid = token.appTid ?? null;
}
return super.fetchJwtBearerToken(assertion, { ...options, app_tid });
}
/**
* @internal
*
* Internal method that MUST NOT be called with tokens before the token has been validated.
* Returns an OAuth id token for the user of the given token.
* If the token is already an id token, it is returned as is.
* If the token is from a technical user, an error is thrown.
* If the token is an access token, it is exchanged for an id token.
*
* Subsequent calls with access tokens will return a cached (but definitely not yet expired) id token.
* The cache is configured by the `idTokenCache` service configuration property of this instance.
*
* @param {IdentityServiceToken} validatedToken - a validated token of a user for which the id token is required
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options
* @returns {Promise<string>} - the id token as raw jwt string
* @throws {Error} if the token is from a technical user
*/
async _getIdToken(validatedToken, options = {}) {
if (validatedToken.subject === validatedToken.azp) {
throw new ConfigurationError("ID tokens cannot be retrieved for technical users.");
}
if (!this.#isAccessToken(validatedToken)) {
return validatedToken.jwt;
}
if (this.idTokenCache) {
return this.#fetchIdTokenCached(validatedToken, options);
} else {
return this.#fetchIdToken(validatedToken, options);
}
}
/**
* Agreed-upon heuristic that derives whether the given token is an access token.
* @param {IdentityServiceToken} validatedToken
* @returns {boolean} true if the token is an access token, false otherwise
*/
#isAccessToken(validatedToken) {
return validatedToken.audiences.length === 1 &&
validatedToken.audiences[0] !== validatedToken.azp;
}
/**
* Retrieves an ID token for the given validated token, using cache when available.
* @param {IdentityServiceToken} validatedToken
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options
* @returns {Promise<string>} ID token
*/
async #fetchIdTokenCached(validatedToken, options = {}) {
const cacheKey = createCacheKey({
ias_iss: validatedToken.issuer,
app_tid: validatedToken.appTid || '',
jti: validatedToken.payload.jti
});
let idTokenJwt = this.idTokenCache.get(cacheKey);
if (idTokenJwt) {
const cachedToken = new IdentityServiceToken(idTokenJwt);
if (cachedToken.remainingTime >= 300) {
// tokens with a minimum remaining time of 5 minutes may be returned from cache
return idTokenJwt;
} else {
// remove almost expired token from cache
this.idTokenCache.set(cacheKey, null);
}
}
idTokenJwt = await this.#fetchIdToken(validatedToken, options);
this.idTokenCache.set(cacheKey, idTokenJwt);
return idTokenJwt;
}
/**
* Retrieves an ID token for the given validated token without using cache.
* @param {IdentityServiceToken} validatedToken
* @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options
* @returns {Promise<string>} ID token
*/
async #fetchIdToken(validatedToken, options = {}) {
const subscriberService = this.#getSubscriberIdentityService(validatedToken);
const response = await subscriberService.fetchJwtBearerToken(validatedToken.jwt, {
token_format: "jwt",
refresh_expiry: 0,
...options
});
return response.id_token;
}
/**
* @internal
* Gets the ID token cache for this IdentityService instance.
*
* @returns {import("../util/Types").Cache} The ID token cache.
*/
get idTokenCache() {
if (this.#idTokenCache === undefined) {
if (this.config.idTokenCache?.impl) {
this.#idTokenCache = this.config.idTokenCache.impl;
} else if (this.config.idTokenCache?.enabled !== false) {
this.#idTokenCache = new LRUCache(this.config.idTokenCache.size);
} else {
this.#idTokenCache = null;
}
}
return this.#idTokenCache;
}
/** @override */
buildTokenRequest(grant_type, options) {
const request = super.buildTokenRequest(grant_type, options);
if (options.resource) {
// multiple resources need to be listed explicitly with multiple resource=<resource> key-value-pairs
[options.resource]
.flatMap(r => r)
.forEach(resource => request.body.append("resource", resource));
}
if (options.refresh_expiry != null) {
request.body.append("refresh_expiry", options.refresh_expiry);
}
return request;
}
/**
* Prepares the given formData and fetch options to use this service's client credentials for authentication.
* Adds clientid, app_tid and either clientsecret or an mTLS agent based on client certificate, depending on the type of credentials.
* @param {RequestInit} request
* @param {URLSearchParams} request.body
* @param {TokenFetchOptions} options
* @override
*/
addClientAuthentication(request, options = {}) {
super.addClientAuthentication(request, options);
const app_tid = options.app_tid === undefined ? this.credentials.app_tid : options.app_tid;
if (app_tid != null) {
request.body.append("app_tid", app_tid);
}
}
/**
* @override
* @inheritdoc
*/
async getTokenUrl(grant_type, options = {}) {
const { correlationId } = options;
const openidConfiguration = await this.getOpenIDConfiguration({ correlationId });
if (!openidConfiguration.grant_types_supported.includes(grant_type)) {
throw new ConfigurationError(`This Identity Service instance does not support grant type ${grant_type} according to its openid-configuration.`);
}
return new URL(openidConfiguration.token_endpoint);
}
/**
* Returns whether proof token validation has been enabled via the service's configuration.
* @returns {Boolean}
*/
hasProofTokenEnabled() {
return this.config.validation?.proofToken?.enabled;
}
/**
* Returns whether x5t proof of token ownership validation has been enabled via the service's configuration.
* @returns {Boolean}
*/
hasX5tEnabled() {
return this.config.validation?.x5t?.enabled;
}
/**
* Returns whether a proof token check has to be done for the given token.
* The decision depends on the type of token.
* Tokens with claim ias_api are App2App tokens for which a proof token check must not be done, even when enabled via the configuration.
* @param {IdentityServiceToken} token
*/
#proofTokenCheckRequired(token) {
return this.hasProofTokenEnabled() && token.payload.ias_apis == null;
}
/**
* Returns a subscriber IdentityService instance based on the given token from the subscriber tenant.
* The returned instance uses this instance's request configuration and client credentials but with the ias_iss URL from the token.
* @param {IdentityServiceToken} token a token from which ias_iss is extracted to build credentials for the subscriber's IdentityService instance
* @returns {IdentityService} IdentityService instance for the subscriber of the token
*/
#getSubscriberIdentityService(token) {
return new IdentityService(
{
...this.credentials,
url: IdentityService.getSafeUrlFromTokenIssuer(token, this.credentials.domains),
},
this.config
);
}
/**
* Returns an issuer URL based on the issuer of the token if it can be succesfully validated against a list of trusted domains.
* @param {IdentityServiceToken} token token from which issuer is extracted
* @param {Array<string>} trustedDomains a list of trusted domains
* @returns {String} URL of issuer if its domain is either a trusted domain or a subdomain of a trusted domain
* @throws {UntrustedIssuerError} if issuer is empty, not trusted or not a valid URL
*/
static getSafeUrlFromTokenIssuer = function (token, trustedDomains = []) {
const issuer = token?.issuer;
if (!issuer) {
throw new MissingIssuerError(token);
}
const issuerUrl = issuer.startsWith(HTTPS_SCHEME) ? issuer : `${HTTPS_SCHEME}${issuer}`;
try {
new URL(issuerUrl);
} catch (e) {
throw new InvalidIssuerError(token, e);
}
const issuerDomain = issuerUrl.substring(HTTPS_SCHEME.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 issuerUrl;
}
}
throw new UntrustedIssuerError(token, issuer, trustedDomains);
}
/**
* Validates that the client owning the given certificate is the owner of the token.
* The validation is based on proof-of-posession via certificate binding of tokens as described in {@link https://datatracker.ietf.org/doc/html/rfc8705 RFC 8705}.
* The validation is succesful if the token contains an base64url-encoded x5t thumbprint under claim {@link CNF_X5T_CLAIM cnf.x5t#S256} that matches the given certificate.
* The client certificate against which the validation is performed, is typically extracted from the {@link FWD_CLIENT_CERT_HEADER x-forwarded-client-cert} request header where it is put by BTP after TLS termination.
* @param {IdentityServiceToken} token
* @param {X509Certificate} cert client certificate parsed as X509Certificate
*/
static validateTokenOwnership(token, cert) {
const tokenX5t = token.payload.cnf?.[X5T_CNF_CLAIM];
if (!tokenX5t) {
throw new X5tError(token, cert, "X5t validation failed because x5t thumbprint could not be found in token.");
}
let certificateX5t;
try {
certificateX5t = Buffer.from(cert.fingerprint256.replaceAll(":", ""), "hex").toString("base64url");
} catch (e) {
throw new X5tError(token, cert, "x5t validation failed because x5t thumbprint could not be calculated from client certificate.");
}
if (tokenX5t !== certificateX5t) {
throw new X5tError(token, cert, "x5t thumbprint did not match the thumbprint of the provided client certificate.");
}
}
/**
* Builds the configuration of this IdentityService based on the provided configuration and default values.
* @param {ServiceConfig & IdentityServiceConfig} config
* @override
*/
static buildServiceConfiguration(config) {
config.idTokenCache ??= IdentityService.DEFAULT_ID_TOKEN_CACHE_CONFIG;
if (config.idTokenCache.enabled === false || config.idTokenCache.size === 0) {
config.idTokenCache = { enabled: false };
} else if (config.idTokenCache.impl) {
config.idTokenCache = { impl: config.idTokenCache.impl, enabled: true };
} else {
config.idTokenCache = { ...IdentityService.DEFAULT_ID_TOKEN_CACHE_CONFIG, ...config.idTokenCache, enabled: true };
}
return config;
}
}
module.exports = IdentityService;