UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

358 lines (311 loc) 14.3 kB
const { Agent } = require("node:https"); const ResponseCache = require("../cache/ResponseCache"); const { ConfigurationError, ExpiredTokenError, InvalidCredentialsError, MissingKidError, NotYetValidTokenError, WrongAudienceError } = require("../error"); const { jsonRequest } = require('../util/jsonRequest'); const { createCacheKey } = require('../util/util'); 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 { static #sharedJwksCaches = {}; static #oidcCache; static DEFAULT_RETRY_CONFIG = { strategy: "exponential", retries: 3, initialDelay: 500, factor: 3, maxDelay: 4000, }; /** @type {ServiceCredentials} */ credentials; /** @type {ServiceConfig} */ config; endpoints; /** * * @param {ServiceCredentials} credentials * @param {ServiceConfig} [serviceConfiguration={}] */ constructor(credentials, serviceConfiguration = {}) { if (credentials == null) { throw new ConfigurationError("Service requires service credentials."); } this.credentials = { ...credentials }; // copy to prevent accidental modifications of original credentials this.config = Service.buildServiceConfiguration(serviceConfiguration); Service.#oidcCache ??= new ResponseCache({ endpointName: "OIDC configuration" }); this.endpoints = { ...this.endpoints, ...(serviceConfiguration.endpoints || {}) }; this.jwksCache = this.config.validation.jwks.shared ? this.#getSharedJwksCache(this.config) : new ResponseCache({ ...this.config.validation.jwks, endpointName: "JWKS" }); } /** * @internal * Gets the OIDC cache shared by all Service instances. * * @returns {import("../cache/ResponseCache")} The OIDC cache. */ get oidcCache() { return Service.#oidcCache; } /** * @internal * Sets the OIDC cache shared by all Service instances. */ set oidcCache(cache) { Service.#oidcCache = cache; } #getSharedJwksCache() { Service.#sharedJwksCaches[this.constructor.name] ??= new ResponseCache({ ...this.config.validation.jwks, endpointName: "JWKS" }); return Service.#sharedJwksCaches[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"); return token.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); 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 }); } /** * 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.endpoints ??= {}; config.endpoints.oidc_info ??= "/.well-known/openid-configuration"; config.validation ??= {}; 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.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;