UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

654 lines (579 loc) 26.8 kB
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;