@sap/xssec
Version:
XS Advanced Container Security API for node.js
654 lines (579 loc) • 26.8 kB
JavaScript
const { Agent } = require("node:https");
const ResponseCache = require("../cache/ResponseCache");
const LRUCache = require("../cache/LRUCache");
const { ConfigurationError, ExpiredTokenError, InvalidCredentialsError, MissingKidError, NotYetValidTokenError, WrongAudienceError } = require("../error");
const { jsonRequest } = require('../util/jsonRequest');
const createCacheKey = require("../cache/createCacheKey");
const { DEFAULT_JWT_BEARER_FETCH_TIMEOUT, DEFAULT_TIMEOUT, GRANTTYPE_CLIENTCREDENTIALS, GRANTTYPE_JWTBEARER, GRANTTYPE_PASSWORD, MAX_TIMEOUT } = require("../util/constants");
/**
* @typedef {import('../token/Token')} Token
* @typedef {import('../context/SecurityContext')} SecurityContext
* @typedef {import('../jwks/Jwks')} Jwks
* @typedef {import('../error/validation/ValidationError')} ValidationError
* @typedef {import('../util/Types').ServiceCredentials} ServiceCredentials
* @typedef {import('../util/Types').ServiceConfig} ServiceConfig
* @typedef {import('../util/Types').SecurityContextConfig} SecurityContextConfig
* @typedef {import('../util/Types').TokenFetchOptions} TokenFetchOptions
* @typedef {import('../util/Types').TokenFetchResponse} TokenFetchResponse
* @typedef {import('../util/Types').GrantType} GrantType
*/
class Service {
/** @type {import("../util/Types").ExponentialBackoffRetryConfig} */
static DEFAULT_RETRY_CONFIG = {
strategy: "exponential",
retries: 3,
initialDelay: 500,
factor: 3,
maxDelay: 4000,
};
/** @type {import("../util/Types").CacheConfig} */
static DEFAULT_SIGNATURE_CACHE_CONFIG = {
size: 100
};
/** @type {import("../util/Types").CacheConfig} */
static DEFAULT_TOKEN_CACHE_CONFIG = {
enabled: true,
size: 100
};
/**
* Minimum remaining token lifetime in seconds for cached tokens.
* Tokens with less remaining lifetime will be refreshed.
* @type {number}
*/
static DEFAULT_TOKEN_CACHE_LEEWAY = 300;
/** @type {ResponseCache} */
static #sharedOidcCache = new ResponseCache({ endpointName: "OIDC configuration" });
/**
* JWKS cache instances shared by Service subclasses indexed by their constructor name.
* @type {Object.<string, ResponseCache>}
*/
static #sharedJwksCaches = {};
/**
* Signature cache instances shared by Service subclasses indexed by their constructor name.
* @type {Object.<string, LRUCache>}
*/
static #sharedSignatureCaches = {};
/** @type {ServiceCredentials} */
credentials;
/** @type {ServiceConfig} */
config;
/** @type {Object.<string, string>} */
#endpoints;
/** @type {ResponseCache} */
#jwksCache;
/**
* A jwt->boolean cache used for signature validation results. Can be either an external cache implementation or one of the {@link #sharedSignatureCaches}.
* @type {import("../util/Types").Cache}
*/
#signatureCache;
/**
* A cache for token fetch responses. Can be either an external cache implementation or a per-instance LRUCache.
* @type {import("../util/Types").Cache}
*/
#tokenCache;
/**
* Context extensions that will be applied when creating security contexts on this instance.
* @type {import("../util/Types").ContextExtension<SecurityContext>[]}
*/
contextExtensions = [];
/**
* @param {ServiceCredentials} credentials
* @param {ServiceConfig} [serviceConfiguration={}]
*/
constructor(credentials, serviceConfiguration = {}) {
if (credentials == null) {
throw new ConfigurationError("Service requires service credentials.");
}
this.credentials = credentials;
this.config = Service.buildServiceConfiguration(serviceConfiguration);
if (this.config.context?.extensions) {
this.contextExtensions = [...this.config.context.extensions];
}
}
/**
* @internal
* Returns the paths of the relevant OIDC endpoints for this service based on
* custom endpoints from service configuration or default values.
*
* @returns {Object.<string, string>} endpoints
*/
get endpoints() {
if (!this.#endpoints) {
this.#endpoints = {
oidc_info: this.config.endpoints?.oidc_info || "/.well-known/openid-configuration",
}
}
return this.#endpoints;
}
/**
* @internal
* Gets the OIDC cache shared by all Service instances.
*
* @returns {import("../cache/ResponseCache")} The OIDC cache.
*/
get oidcCache() {
return Service.#sharedOidcCache;
}
/**
* @internal
* Sets the OIDC cache shared by all Service instances.
*/
set oidcCache(cache) {
Service.#sharedOidcCache = cache;
}
/**
* @internal
* Gets the JWKS cache for this Service instance.
*
* @returns {import("../cache/ResponseCache")} The JWKS cache.
*/
get jwksCache() {
if (!this.#jwksCache) {
this.#jwksCache = this.config.validation.jwks.shared ? this.#getSharedJwksCache(this.config) : new ResponseCache({ ...this.config.validation.jwks, endpointName: "JWKS" });
}
return this.#jwksCache;
}
/**
* @internal
* Gets the signature cache for this Service instance.
*
* @returns {import("../util/Types").Cache} The signature cache.
*/
get signatureCache() {
if (this.#signatureCache === undefined) {
if (this.config.validation.signatureCache.impl) {
this.#signatureCache = this.config.validation.signatureCache.impl;
} else if (this.config.validation.signatureCache.enabled !== false) {
this.#signatureCache = Service.#getSharedSignatureCache(this.config.validation.signatureCache);
} else {
this.#signatureCache = null;
}
}
return this.#signatureCache;
}
/**
* Gets the token fetch cache for this Service instance.
* Can be used to re-use the cache when creating a second Service instance, e.g.:
* ```js
* const service2 = new XsuaaService(credentials, { tokenfetch: { cache: { impl: service1.tokenFetchCache } } });
* ```
*
* @returns {import("../util/Types").Cache} The token fetch cache, or null if token fetch caching is disabled.
*/
get tokenFetchCache() {
if (this.#tokenCache === undefined) {
if (this.config.tokenfetch.cache.impl) {
this.#tokenCache = this.config.tokenfetch.cache.impl;
} else if (this.config.tokenfetch.cache.enabled !== false) {
this.#tokenCache = new LRUCache(this.config.tokenfetch.cache.size);
} else {
this.#tokenCache = null;
}
}
return this.#tokenCache;
}
#getSharedJwksCache() {
Service.#sharedJwksCaches[this.constructor.name] ??= new ResponseCache({ ...this.config.validation.jwks, endpointName: "JWKS" });
return Service.#sharedJwksCaches[this.constructor.name];
}
/**
* Retrieves or creates the signature cache shared by all instances of this Service subclass.
* @param {import("../util/Types").CacheConfig} config
* @returns {LRUCache} the shared signature cache
* @throws {ConfigurationError} if a shared signature cache with a different size has already been created by another Service configuration for the same Service subclass.
*/
static #getSharedSignatureCache(config) {
const sharedCache = Service.#sharedSignatureCaches[this.constructor.name];
if (sharedCache != null && sharedCache.size !== config.size) {
throw new ConfigurationError(
`An internal signature cache with size ${sharedCache.size} instead of ${config.size} ` +
`has already been created by another ${this.constructor.name} configuration. ` +
`Please use the same size in all ${this.constructor.name} configurations or provide separate, externally managed cache implementations.`
);
}
Service.#sharedSignatureCaches[this.constructor.name] ??= new LRUCache(config.size);
return Service.#sharedSignatureCaches[this.constructor.name];
}
/**
* Checks if this service is the recipient of the given token.
* @param {Token} token
* @returns {Boolean}
*/
acceptsTokenAudience(token) {
this.validateCredentials("validate token audience", "clientid");
const audiences = token.audiences;
if(audiences == null) {
throw new WrongAudienceError(token, this, "Token is missing an audience which is required to validate whether this client may use it.");
}
return audiences.includes(this.credentials.clientid);
}
/**
* Called internally to validate the credentials to have the necessary properties before performing a specific action, e.g. token fetch.
*
* @internal
* @param {string} action description of action for which the credentials are being validated.
* @param {...string} mandatoryProperties mandatory properties that must be present in the credentials.
* @throws {InvalidCredentialsError} if any of the mandatory properties are missing in the credentials.
*/
validateCredentials(action, ...mandatoryProperties) {
const missingProperties = mandatoryProperties.filter(p => !this.credentials[p]);
if (missingProperties.length > 0) {
throw new InvalidCredentialsError(`${this.constructor.name} is missing the properties ${missingProperties} inside its credentials for: ${action}.`);
}
}
/**
* Checks if the given token is valid under the given contextConfig.
* @param {Token} token
* @param {SecurityContextConfig} contextConfig
* @throws {ValidationError} if the token is not valid or could not be validated
*/
async validateToken(token, contextConfig) {
if (token.expired) {
throw new ExpiredTokenError(token);
}
if (token.notYetValid) {
throw new NotYetValidTokenError(token);
}
if (!this.acceptsTokenAudience(token)) {
throw new WrongAudienceError(token, this);
}
await this.validateTokenSignature(token, contextConfig);
}
/**
* Checks if the given token's signature is valid under the given contextConfig.
* @param {Token} token
* @param {SecurityContextConfig} contextConfig
* @returns {Promise<void>} resolves when token signature is valid, otherwise error is thrown
* @throws {ValidationError} if the token signature is not valid or could not be validated
*/
async validateTokenSignature(token, contextConfig) {
if (!token.header.kid) {
throw new MissingKidError(token.header.kid, `Token header contained no kid.`);
}
const jwks = await this.getJwks(token, contextConfig);
const jwk = jwks.get(token.header.kid);
if (this.signatureCache) {
jwk.validateSignature(token, this.signatureCache);
} else {
jwk.validateSignature(token);
}
}
/**
* @param {object} [requestOptions]
* @param {string} [requestOptions.correlationId]
*/
async getOpenIDConfiguration({ correlationId } = {}) {
this.validateCredentials("fetch OIDC configuration", "url");
const cacheKey = createCacheKey({ url: this.credentials.url });
const buildRequest = () => {
return (correlationId) => this.fetchOpenIDConfiguration({ correlationId });
}
return this.oidcCache.getOrRequest(cacheKey, buildRequest, { correlationId });
}
/**
* @param {object} [requestOptions]
* @param {string} [requestOptions.correlationId]
*/
async fetchOpenIDConfiguration({ correlationId } = {}) {
this.validateCredentials("fetch OIDC configuration", "url");
const oidcUrl = new URL(this.endpoints.oidc_info, this.credentials.url);
const request = this.buildRequest({
method: 'GET',
});
return jsonRequest(oidcUrl, request, { requestName: `${this.constructor.name}.fetchOpenIDConfiguration`, correlationId });
}
/**
* Gets a cached client credentials token or fetches a new one if not cached or expired.
* Uses the token cache configured on this service instance to store token responses.
* Cached tokens are re-used for subsequent requests with the same parameters.
* The minimum guaranteed lifetime of returned tokens is 5 minutes.
* If no token cache is configured on this service instance, the token is fetched directly without caching.
*
* @param {TokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>} response
*/
async getClientCredentialsToken(options = {}) {
if (!this.tokenFetchCache) {
return this.fetchClientCredentialsToken(options);
}
const grant_type = GRANTTYPE_CLIENTCREDENTIALS;
const tokenUrl = await this.getTokenUrl(grant_type, options);
const request = this.buildTokenRequest(grant_type, options);
const cacheKey = this.#buildTokenCacheKey(tokenUrl, request);
return this.#getOrFetchToken(cacheKey, () => this.fetchClientCredentialsToken(options));
}
/**
* Gets a cached JWT bearer token or fetches a new one if not cached or expired.
* Uses the token cache configured on this service instance to store token responses.
* Cached tokens are re-used for subsequent requests with the same parameters.
* The minimum guaranteed lifetime of returned tokens is 5 minutes.
* If no token cache is configured on this service instance, the token is fetched directly without caching.
*
* @param {String} assertion - JWT bearer token used as assertion
* @param {TokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>} response
*/
async getJwtBearerToken(assertion, options = {}) {
if (!this.tokenFetchCache) {
return this.fetchJwtBearerToken(assertion, options);
}
const grant_type = GRANTTYPE_JWTBEARER;
const tokenUrl = await this.getTokenUrl(grant_type, options);
const request = this.buildTokenRequest(grant_type, options);
request.body.append("assertion", assertion);
const cacheKey = this.#buildTokenCacheKey(tokenUrl, request);
return this.#getOrFetchToken(cacheKey, () => this.fetchJwtBearerToken(assertion, options));
}
/**
* Gets a cached password token or fetches a new one if not cached or expired.
* Uses the token cache configured on this service instance to store token responses.
* Cached tokens are re-used for subsequent requests with the same parameters.
* The minimum guaranteed lifetime of returned tokens is 5 minutes.
* If no token cache is configured on this service instance, the token is fetched directly without caching.
*
* @param {String} username
* @param {String} password
* @param {TokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>} response
*/
async getPasswordToken(username, password, options = {}) {
if (!this.tokenFetchCache) {
return this.fetchPasswordToken(username, password, options);
}
const grant_type = GRANTTYPE_PASSWORD;
const tokenUrl = await this.getTokenUrl(grant_type, options);
const request = this.buildTokenRequest(grant_type, options);
request.body.append("username", username);
request.body.append("password", password);
const cacheKey = this.#buildTokenCacheKey(tokenUrl, request);
return this.#getOrFetchToken(cacheKey, () => this.fetchPasswordToken(username, password, options));
}
/**
* Builds a cache key from the token URL and request for token caching.
* Includes URL, all body parameters, and headers - if present.
*
* @private
* @param {URL} url - The token endpoint URL
* @param {Object} request - The request object with body and headers
* @returns {String} The cache key
*/
#buildTokenCacheKey(url, request) {
const bodyParams = {};
for (const [key, value] of request.body.entries()) {
bodyParams[key] = value;
}
const keyParts = {
url: url.toString(),
...bodyParams
};
if (request.headers) {
for (const [key, value] of Object.entries(request.headers)) {
keyParts[`header_${key}`] = value;
}
}
return createCacheKey(keyParts);
}
/**
* Internal method that implements the caching logic for token fetching.
* Checks the service's token cache for a valid token, fetches a new one if needed, and stores it with an expiration timestamp.
*
* @private
* @param {String} cacheKey - The cache key for storing/retrieving the token
* @param {Function} fetchToken - Async function that fetches the token
* @returns {Promise<TokenFetchResponse>} response
*/
async #getOrFetchToken(cacheKey, fetchToken) {
const cachedEntry = this.tokenFetchCache.get(cacheKey);
if (cachedEntry) {
const { response, expiresAt } = cachedEntry;
const remainingSeconds = (expiresAt - Date.now()) / 1000;
if (remainingSeconds > Service.DEFAULT_TOKEN_CACHE_LEEWAY) {
return response;
}
}
const response = await fetchToken();
const expiresAt = Date.now() + (response.expires_in * 1000);
this.tokenFetchCache.set(cacheKey, { response, expiresAt });
return response;
}
/**
* Fetches a token from this service with this service's client credentials.
* @param {TokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>} response
*/
async fetchClientCredentialsToken(options = {}) {
const grant_type = GRANTTYPE_CLIENTCREDENTIALS;
const tokenUrl = await this.getTokenUrl(grant_type, options);
const request = this.buildTokenRequest(grant_type, options);
return jsonRequest(tokenUrl, request, { requestName: `${this.constructor.name}.fetchClientCredentialsToken`, correlationId: options.correlationId });
}
/**
* Fetches a user token from this service with the given username and password.
* @param {String} username
* @param {String} password
* @param {TokenFetchOptions} options
* @returns {Promise<TokenFetchResponse>} response
*/
async fetchPasswordToken(username, password, options = {}) {
const grant_type = GRANTTYPE_PASSWORD;
const tokenUrl = await this.getTokenUrl(grant_type, options);
const request = this.buildTokenRequest(grant_type, options);
request.body.append("username", username);
request.body.append("password", password);
return jsonRequest(tokenUrl, request, { requestName: `${this.constructor.name}.fetchPasswordToken`, correlationId: options.correlationId });
}
/**
* Fetches a JWT bearer token from this service with the given user token as assertion.
* @param {TokenFetchOptions} options - default timeout is 10 seconds as JWT bearer can be slow
* @returns {Promise<TokenFetchResponse>} response
*/
async fetchJwtBearerToken(assertion, options = {}) {
const grant_type = GRANTTYPE_JWTBEARER;
const tokenUrl = await this.getTokenUrl(grant_type, options);
options.timeout ??= DEFAULT_JWT_BEARER_FETCH_TIMEOUT;
const request = this.buildTokenRequest(grant_type, options);
request.body.append("assertion", assertion);
return jsonRequest(tokenUrl, request, { requestName: `${this.constructor.name}.fetchJwtBearerToken`, correlationId: options.correlationId });
}
/**
* Builds a request for this service based on the service configuration and the given request options.
* For example, the request will use the timeout value from the service configuration if not overridden in the request options.
*
* @internal
* @param {import("node:https").RequestOptions} [requestOptions] - options for the request
*/
buildRequest(requestOptions) {
return {
timeout: this.config.requests.timeout,
retry: this.config.requests.retry,
...requestOptions
};
}
/**
* Builds a token request for this service with the given grant_type and options.
*
* @param {String} grant_type
* @param {TokenFetchOptions} options
*/
buildTokenRequest(grant_type, options) {
const request = this.buildRequest({
method: "POST",
body: new URLSearchParams({
grant_type
})
});
this.addClientAuthentication(request, options);
if (options.timeout) {
request.timeout = options.timeout;
}
if (options.token_format) {
request.body.append("token_format", options.token_format);
}
return request;
}
/**
* Prepares the given request to use this service's client credentials for authentication.
* Adds clientid 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
*/
addClientAuthentication(request, options = {}) {
this.validateCredentials("fetch token", "clientid");
request.body.append("client_id", this.credentials.clientid);
if (this.credentials.clientsecret) {
request.body.append("client_secret", this.credentials.clientsecret);
} else if (this.credentials.key && this.credentials.certificate) {
request.agent = new Agent({
key: this.credentials.key,
cert: this.credentials.certificate,
});
} else {
throw new InvalidCredentialsError("Service credentials contain neither a client secret nor certificate based authentication information.");
}
}
/**
* Updates the certificate and key in the service credentials for authentication of subsequent requests.
* @param {String} cert PEM-encoded client certificate
* @param {String} key PEM-encoded client key
* @returns {void}
*/
setCertificateAndKey(cert, key) {
this.credentials.certificate = cert;
this.credentials.key = key;
}
/**
* Builds the configuration of this service based on the provided configuration and default values.
* @param {ServiceConfig} config
*/
static buildServiceConfiguration(config) {
config.validation ??= {};
config.validation.enabled ??= true;
config.validation.jwks ??= {};
config.validation.jwks.shared ??= false;
config.validation.jwks.expirationTime ??= ResponseCache.DEFAULT_EXPIRATION_TIME;
config.validation.jwks.refreshPeriod ??= ResponseCache.DEFAULT_REFRESH_PERIOD;
config.validation.signatureCache ??= { enabled: false };
if (config.validation.signatureCache.enabled === false || config.validation.signatureCache.size === 0) {
config.validation.signatureCache = { enabled: false }; // remove unused config properties like size
} else if (config.validation.signatureCache.impl) {
config.validation.signatureCache = { impl: config.validation.signatureCache.impl, enabled: true }; // remove unused config properties like size
} else {
// fill missing properties with defaults
config.validation.signatureCache = { ...Service.DEFAULT_SIGNATURE_CACHE_CONFIG, ...config.validation.signatureCache, enabled: true }; // use default signature cache configuration
}
config.tokenfetch ??= {};
config.tokenfetch.cache ??= Service.DEFAULT_TOKEN_CACHE_CONFIG;
const tc = config.tokenfetch.cache;
if (tc.enabled === false || tc.size === 0) {
config.tokenfetch.cache = { enabled: false };
} else if (tc.impl) {
config.tokenfetch.cache = { impl: tc.impl, enabled: true };
} else {
config.tokenfetch.cache = { ...Service.DEFAULT_TOKEN_CACHE_CONFIG, ...tc, enabled: true };
}
config.requests ??= {};
config.requests.timeout = Math.min(MAX_TIMEOUT, config.requests.timeout ?? DEFAULT_TIMEOUT);
if (config.requests.retry) {
if (config.requests.retry === true) {
config.requests.retry = Service.DEFAULT_RETRY_CONFIG;
} else {
config.requests.retry = { ...Service.DEFAULT_RETRY_CONFIG, ...config.requests.retry };
}
}
return config;
}
/**
* Creates a new {@link SecurityContext} from this service with the given token.
* @abstract
* @param {String|Token} token as JWT or Token object
* @param {SecurityContextConfig} contextConfig
* @returns {Promise<SecurityContext>} securityContext
*/
async createSecurityContext(token, contextConfig = {}) {
throw new ConfigurationError("This abstract function MUST be called on a service-specific implementation.");
}
/**
* 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 {Error} If an error occurs while retrieving the JWKS.
*/
async getJwks(token, contextConfig) {
throw new ConfigurationError("This abstract function MUST be called on a service-specific implementation.");
}
/**
* Determines the URL that can be used for fetching tokens of given grant_type from this service.
* @abstract
* @param {GrantType} grant_type
* @param {Object} options
* @param {String} options.correlationId
* @returns {Promise<URL>} URL of the service's token endpoint
*/
async getTokenUrl(grant_type, options = {}) {
throw new ConfigurationError("This abstract function MUST be called on a service-specific implementation.");
}
}
module.exports = Service;