UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

217 lines (195 loc) 11.6 kB
const LRUCache = require("../cache/LRUCache"); const { ConfigurationError } = require("../error"); const createCacheKey = require("../cache/createCacheKey"); const createSecurityContext = require("./createSecurityContext"); const { getLogger } = require("../util/logging"); const XsuaaService = require("../service/XsuaaService"); const IdentityServiceToken = require("../token/IdentityServiceToken"); const LOG = getLogger("XsuaaLegacyExtension.js"); const IDENTITY_SERVICE_SECURITY_CONTEXT = "IdentityServiceSecurityContext"; const XSUAA_SECURITY_CONTEXT = "XsuaaSecurityContext"; /** * XsuaaLegacyExtension * --------------------------------------------- * * When this extension is enabled, it extends {@link IdentityServiceSecurityContext}s created on that service * by exchanging IAS user tokens for XSUAA user tokens. * Important: It *never* applies to XSUAA tokens or IAS technical user tokens. In these cases, the result of createSecurityContext is unaffected by this extension! * Non-CAP applications with hybrid authentication must be prepared to deal with both context types depending on the incoming tokens. * * By default, it will embed the XsuaaSecurityContext created from the fetched token to the optional property `xsuaaContext`: * * IdentityServiceSecurityContext\ * └─ xsuaaContext: XsuaaSecurityContext * * Alternatively, it can be configured the other way around, so that `createSecurityContext` will instead return the XsuaaSecurityContext with the original * IdentityServiceSecurityContext embedded inside on property `iasContext`: * * XsuaaSecurityContext\ * └─ iasContext: IdentityServiceSecurityContext * * This is controlled by passing either the IdentityServiceSecurityContext constructor name or the XsuaaSecurityContext constructor name to `primaryContextType`. * * Use case: Migrate XSUAA-based authentication/authorization to IAS (and optionally AMS) for some tenants. * - Some tenants may still log-in via XSUAA sending XSUAA tokens * ==> createSecurityContext will always return XsuaaSecurityContext independent of this extension * - Other tenants have migrated already to log-in via IAS sending IAS tokens * ==> When this extension is enabled, createSecurityContext will return IdentityServiceSecurityContext with an embedded XsuaaSecurityContext or vice versa - depending on configuration * * Hybrid applications that have not adopted AMS, can use `primaryContextType='XsuaaSecurityContext'` to keep using their existing XSUAA-based authorization checks.\ * Hybrid applications that have adopted AMS, can use `primaryContextType='IdentityServiceSecurityContext'` to get the IAS context as primary one and map * XSUAA scopes to additional base policies for users of legacy tenants, so the existing XSUAA authorizations stay in effect until they have fully migrated user roles to AMS policies. * * Conditional application: * - The method {@link appliesTo} determines per created IdentityServiceSecurityContext whether the XsuaaLegacyExtension shall be applied or not. * - Default implementation: applies only to regular user tokens because technical user tokens cannot be exchanged. * - You can subclass or supply an override of this method to implement additional tenant allow-lists / feature flags. * * **When the extension does not apply, createSecurityContext will result in an IdentityServiceSecurityContext, even for `primaryContextType=XsuaaSecurityContext`!** * * Token fetching logic: * - If the incoming IAS token is a "weak" (app2app) token (identified by `consumedApis.length > 0`), it is first exchanged for a strong JWT by `IdentityServiceSecurityContext#getIdToken`. * - Then, an XSUAA JWT is requested from the given XsuaaService using the (possibly exchanged) IAS token as assertion in a `jwt bearer` flow. * - An `XsuaaSecurityContext` is created from the resulting XSUAA JWT. * * Caching: * - The created XsuaaSecurityContext is cached by the original IAS token's ias_iss, jti and (if present) app_tid until 5 minutes before expiration. * - Default cache: LRU cache with max size 100. A custom cache (implementing { get(key), set(key, value) }) can be configured via cache configuration using the `impl` property. */ class XsuaaLegacyExtension { static IDENTITY_SERVICE_SECURITY_CONTEXT = IDENTITY_SERVICE_SECURITY_CONTEXT; static XSUAA_SECURITY_CONTEXT = XSUAA_SECURITY_CONTEXT; /** @type {import("../util/Types").CacheConfig} */ static DEFAULT_CACHE_CONFIG = { enabled: true, size: 100 }; /** * Indicates which context type should become the primary one returned to the application. * @type {IDENTITY_SERVICE_SECURITY_CONTEXT | XSUAA_SECURITY_CONTEXT} */ primaryContextType; /** * A cache used for caching XsuaaSecurityContexts. Can be either an external cache implementation or a per-instance LRUCache. * @type {import("../util/Types").Cache} */ cache; /** * Create an XsuaaLegacyExtension instance. * * @param {import("../util/Types").XsuaaLegacyExtensionConfig} [config] - Configuration for the extension. */ constructor(config = {}) { if (config.primaryContextType != null && config.primaryContextType !== XSUAA_SECURITY_CONTEXT && config.primaryContextType !== IDENTITY_SERVICE_SECURITY_CONTEXT) { throw new ConfigurationError( `XsuaaLegacyExtension: 'primaryContextType' must be either '${IDENTITY_SERVICE_SECURITY_CONTEXT}', '${XSUAA_SECURITY_CONTEXT}' or null/undefined (defaults to '${IDENTITY_SERVICE_SECURITY_CONTEXT}').` ); } this.primaryContextType = config.primaryContextType ?? IDENTITY_SERVICE_SECURITY_CONTEXT; this.cache = this.#initializeCache(config.cache); } /** * Initialize cache from the provided cache config. * @private * @param {import("../util/Types").CacheConfig} [cacheConfig] - Cache configuration. * @returns {import("../util/Types").Cache} The initialized cache instance. */ #initializeCache(cacheConfig) { cacheConfig ??= XsuaaLegacyExtension.DEFAULT_CACHE_CONFIG; if (cacheConfig.enabled === false || cacheConfig.size === 0) { return null; } else if (cacheConfig.impl) { return cacheConfig.impl; } else { const size = cacheConfig.size ?? XsuaaLegacyExtension.DEFAULT_CACHE_CONFIG.size; return new LRUCache(size); } } /** * Extend (augment) an IAS security context with an XSUAA context, or replace it as primary depending on `primaryContextType`. * @param {import("./IdentityServiceSecurityContext")} iasCtx - The initial IAS-derived security context. * @returns {Promise<import("./IdentityServiceSecurityContext") | import("./XsuaaSecurityContext") | undefined>} The primary context (after augmentation) or undefined if no augmentation occurred. */ async extendSecurityContext(iasCtx) { if (await this.appliesTo(iasCtx)) { LOG.debug?.(`Applying extension to SAP Identity Service token with app_tid='${iasCtx.token.appTid}'.`); const xsuaaContext = await this.getXsuaaContext(iasCtx); if (this.primaryContextType === IDENTITY_SERVICE_SECURITY_CONTEXT) { iasCtx.xsuaaContext = xsuaaContext; LOG.debug?.(`Successfully embedded XsuaaSecurityContext under property 'xsuaaContext'.`); return iasCtx; } else if (this.primaryContextType === XSUAA_SECURITY_CONTEXT) { xsuaaContext.iasContext = iasCtx; LOG.debug?.(`Successfully swapped IdentityServiceSecurityContext to an XsuaaSecurityContext with original context embedded under 'iasContext' property.`); return xsuaaContext; } } else { LOG.debug?.(`Skipped extension for SAP Identity Service token with app_tid='${iasCtx.token.appTid}'.`); } } /** * Decides whether the extension should fetch an XsuaaSecurityContext for the given IdentityServiceSecurityContext. * Override this for custom logic, e.g. decision per tenant. * Default: applies to user tokens only (`sub !== azp`). * @param {import("./IdentityServiceSecurityContext")} iasCtx * @returns {Promise<boolean>} */ async appliesTo(iasCtx) { return iasCtx.token.subject !== iasCtx.token.azp; } /** * Obtain (and cache) an XsuaaSecurityContext corresponding to the provided IdentityServiceSecurityContext. * Handles weak-token exchange (app2app) before requesting an XSUAA JWT. * @param {import("./IdentityServiceSecurityContext")} iasCtx * @returns {Promise<import("./XsuaaSecurityContext")>} */ async getXsuaaContext(iasCtx) { const iasToken = iasCtx.token; let cacheKey = createCacheKey({ ias_iss: iasToken.issuer, app_tid: iasToken.appTid || '', jti: iasToken.payload.jti }); /** @type {XsuaaSecurityContext|null|undefined} */ const cachedXsuaaContext = this.cache?.get(cacheKey); if (cachedXsuaaContext) { if (cachedXsuaaContext.token.remainingTime >= 300) { // tokens with a minimum remaining time of 5 minutes may be returned from cache LOG.debug?.(`Returning cached XsuaaSecurityContext for IAS token with app_tid='${iasToken.appTid}', jti='${iasToken.payload.jti}', ias_iss='${iasToken.issuer}'.`); return cachedXsuaaContext; } else { // remove almost expired token from cache LOG.debug?.(`Removing almost expired cached XsuaaSecurityContext for IAS token with app_tid='${iasToken.appTid}', jti='${iasToken.payload.jti}', ias_iss='${iasToken.issuer}'.`); this.cache.set(cacheKey, null); } } const idTokenJwt = await iasCtx.getIdToken(); const idToken = new IdentityServiceToken(idTokenJwt); const xsuaaService = this.findXsuaaService(iasCtx.config.services, idToken); LOG.debug?.(`Fetching XSUAA token for IAS token with app_tid='${iasToken.appTid}', jti='${iasToken.payload.jti}', ias_iss='${iasToken.issuer}'.`); const xsuaaJwt = (await xsuaaService.fetchJwtBearerToken(idTokenJwt, { zid: idToken.appTid })).access_token; const xsuaaContext = await createSecurityContext(xsuaaService, { ...iasCtx.config, jwt: xsuaaJwt, token: undefined, skipValidation: true // token fetched from XsuaaService can be trusted }); this.cache?.set(cacheKey, xsuaaContext); return xsuaaContext; } /** * Returns the first XsuaaService instance from the list of services and implements error handling and debug logging. * @param {import("../service/Service")} services */ findXsuaaService(services) { const xsuaaServices = services.filter(s => s instanceof XsuaaService); if (xsuaaServices.length < 1) { throw new ConfigurationError(`XsuaaLegacyExtension enabled but no XsuaaService instance was passed to createSecurityContext. Please pass a Service array of both IdentityService and XsuaaService (in this order).`); } else if (xsuaaServices.length > 1) { LOG.debug?.(`Multiple XsuaaService instances were passed to createSecurityContext. The first one in the list will be used for the token exchange.`); } return xsuaaServices[0]; } } module.exports = XsuaaLegacyExtension;