@sap/xssec
Version:
XS Advanced Container Security API for node.js
217 lines (195 loc) • 11.6 kB
JavaScript
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;