UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

525 lines (452 loc) 23 kB
'use strict'; /** * @typedef {import("crypto").X509Certificate} X509Certificate */ const Service = require('./Service'); const SecurityContext = require('../context/SecurityContext'); const IdentityServiceSecurityContext = require('../context/IdentityServiceSecurityContext'); const IdentityServiceToken = require('../token/IdentityServiceToken'); const Jwks = require('../jwks/Jwks'); const LRUCache = require('../cache/LRUCache'); const { MissingClientCertificateError, MissingIssuerError, InvalidIssuerError, UntrustedIssuerError, X5tError, ConfigurationError, ResponseError, WrongAudienceError } = require('../error'); const { parsePemCertificate } = require('../util/certs'); const createCacheKey = require('../cache/createCacheKey'); const { escapeStringForRegex } = require('../util/util'); const { jsonRequest } = require('../util/jsonRequest'); const { APP_TID_HEADER, AZP_HEADER, CLIENT_CERTIFICATE_HEADER, CLIENTID_HEADER, HTTPS_SCHEME, SERVICE_PLAN_HEADER, X5T_CNF_CLAIM } = require('../util/constants'); const Token = require("../token/Token"); const XsuaaLegacyExtension = require("../context/XsuaaLegacyExtension"); /** * @typedef {import('../util/Types').ServiceCredentials} ServiceCredentials * @typedef {import('../util/Types').IdentityServiceCredentials} IdentityServiceCredentials * @typedef {import('../util/Types').ServiceConfig} ServiceConfig * @typedef {import('../util/Types').IdentityServiceConfig} IdentityServiceConfig * * @typedef {import('../util/Types').SecurityContextConfig} SecurityContextConfig * * @typedef {import('../util/Types').TokenFetchOptions} TokenFetchOptions * @typedef {import('../util/Types').IdentityServiceTokenFetchOptions} IdentityServiceTokenFetchOptions * @typedef {import('../util/Types').TokenFetchResponse} TokenFetchResponse * @typedef {import('../util/Types').IdTokenFetchResponse} IdTokenFetchResponse * @typedef {import('../util/Types').RefreshableTokenFetchResponse} RefreshableTokenFetchResponse */ /** * This {@link Service} class is constructed from SAP Identity Service credentials to provide an API with selected functionality against that service instance, e.g. token validation and token fetches. */ class IdentityService extends Service { /** @type {import("../util/Types").CacheConfig} */ static DEFAULT_ID_TOKEN_CACHE_CONFIG = { enabled: true, size: 100 }; /** * A string->jwt cache used for caching ID tokens for {@link _getIdToken}. Can be either an external cache implementation or a per-instance LRUCache. * @type {import("../util/Types").Cache} */ #idTokenCache; /** * @param {ServiceCredentials & IdentityServiceCredentials} credentials * @param {ServiceConfig & IdentityServiceConfig} [serviceConfiguration={}] */ constructor(credentials, serviceConfiguration) { super(credentials, serviceConfiguration); this.config = IdentityService.buildServiceConfiguration(this.config); if (this.config.xsuaaLegacyExtension) { const extensionConfig = this.config.xsuaaLegacyExtension === true ? {} : this.config.xsuaaLegacyExtension; this.contextExtensions.push(new XsuaaLegacyExtension(extensionConfig)); } } /** * @override * @param {String|IdentityServiceToken} token token as JWT or IdentityServiceToken object * @param {SecurityContextConfig} contextConfig * @returns {Promise<IdentityServiceSecurityContext>} */ async createSecurityContext(token, contextConfig = {}) { if (typeof token === "string") { token = new IdentityServiceToken(token); } else if (token instanceof Token && !(token instanceof IdentityServiceToken)) { token = new IdentityServiceToken(token.jwt, { header: token.header, payload: token.payload }); } SecurityContext.buildContextConfig(contextConfig); if (contextConfig.skipValidation !== true && this.config.validation.enabled !== false) { if (this.#proofTokenCheckRequired(token) || this.hasX5tEnabled()) { if (contextConfig.clientCertificatePem == null) { throw new MissingClientCertificateError(); } else { contextConfig.clientCertificate = parsePemCertificate(contextConfig.clientCertificatePem); } } await this.validateToken(token, contextConfig); } let ctx = new IdentityServiceSecurityContext(this, token, contextConfig); for (let extension of this.contextExtensions) { ctx = await extension.extendSecurityContext(ctx) ?? ctx; } return ctx; } /** * @override * @param {IdentityServiceToken} token * @param {SecurityContextConfig} contextConfig */ async validateToken(token, contextConfig) { if (this.hasX5tEnabled()) { IdentityService.validateTokenOwnership(token, contextConfig.clientCertificate); } // expiration time, audience, signature etc. await super.validateToken(token, contextConfig); } /** * 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 {WrongAudienceError} - if the online validation in the JWKS endpoint of the Identity Service instance responds with a 400 status code, indicating that the token should not be accepted by this service. * @throws {Error} - If an error occurs while retrieving the JWKS. */ async getJwks(token, contextConfig) { this.validateCredentials("fetch JWKS", "clientid", "url"); // throws for malicious issuers const issuerUrl = IdentityService.getSafeUrlFromTokenIssuer(token, this.credentials.domains); const jwksParams = { clientid: this.credentials.clientid, app_tid: token.appTid, azp: token.azp } const keyParts = { url: issuerUrl, ...jwksParams }; const proofTokenCheck = this.#proofTokenCheckRequired(token); if (proofTokenCheck) { keyParts.certSub = contextConfig.clientCertificate?.subject; jwksParams.clientCertificatePem = contextConfig.clientCertificatePem?.replaceAll("\\n", "").replaceAll("\n", ""); } const cacheKey = createCacheKey(keyParts); const buildJwksRequest = () => { // fetch JWKS either from this service if it has issued the token, or if not, from the trusted issuer service using this service's client id const issuerService = issuerUrl === this.credentials.url ? this : this.#getSubscriberIdentityService(token); const extractHeaders = proofTokenCheck ? "headers" : null; return async (correlationId) => { const jwksResponse = await issuerService.fetchJwks(jwksParams, { correlationId, extractHeaders }); return { jwks: new Jwks(jwksResponse.keys), servicePlans: proofTokenCheck ? jwksResponse.headers.get(SERVICE_PLAN_HEADER)?.split(",").map(plan => plan.replaceAll("\"", "")) : null }; } } let cachedResponse; try { cachedResponse = await this.jwksCache.getOrRequest(cacheKey, buildJwksRequest, { correlationId: contextConfig.correlationId }); } catch (error) { if (error instanceof ResponseError && error.responseCode === 400 && error.request.name === `${this.constructor.name}.fetchJwks`) { /** * Online validation in JWKS endpoint may have negative result for the provided header / query parameters. * In this case, the response will have status 400 and should result in a WrongAudienceError instead of a (subclass of) NetworkError. */ throw new WrongAudienceError(token, this, `The online validation in the JWKS endpoint of the Identity Service instance responded with 400: '${error.responseText}'. This indicates that the token should not be accepted by this service.`); } throw error; } const { jwks, servicePlans } = cachedResponse; if (proofTokenCheck) { contextConfig.servicePlans = servicePlans; } return jwks; } async fetchJwks({ clientid, app_tid, azp, clientCertificatePem }, { correlationId, extractHeaders }) { const openIDConfiguration = await this.getOpenIDConfiguration({ correlationId }); const jwksUrl = openIDConfiguration.jwks_uri; const request = this.buildRequest({ method: 'GET', headers: { [CLIENTID_HEADER]: clientid } }); if (app_tid != null) { request.headers[APP_TID_HEADER] = app_tid; } if (azp != null) { request.headers[AZP_HEADER] = azp; } if (clientCertificatePem != null) { request.headers[CLIENT_CERTIFICATE_HEADER] = clientCertificatePem } return jsonRequest(jwksUrl, request, { requestName: `${this.constructor.name}.fetchJwks`, correlationId, extractHeaders }); } // Re-declare JSDoc for token fetches with detailed options and return object properties /** * Fetches a token from this service with this service's client credentials. * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options * @returns {Promise<TokenFetchResponse>} */ async fetchClientCredentialsToken(options = {}) { return super.fetchClientCredentialsToken(options); } /** * Fetches a user token from this service with the given username and password. * @param {String} username * @param {String} password * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options * @returns {Promise<TokenFetchResponse & IdTokenFetchResponse & RefreshableTokenFetchResponse>} */ async fetchPasswordToken(username, password, options = {}) { return super.fetchPasswordToken(username, password, options); } /** * Fetches a JWT bearer token from this service with the given user token as assertion. * @param {String} assertion JWT bearer token used as assertion * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} options * @returns {Promise<TokenFetchResponse & IdTokenFetchResponse & RefreshableTokenFetchResponse>} */ async fetchJwtBearerToken(assertion, options = {}) { let app_tid = options.app_tid; if(app_tid === undefined) { const token = new IdentityServiceToken(assertion); app_tid = token.appTid ?? null; } return super.fetchJwtBearerToken(assertion, { ...options, app_tid }); } /** * @internal * * Internal method that MUST NOT be called with tokens before the token has been validated. * Returns an OAuth id token for the user of the given token. * If the token is already an id token, it is returned as is. * If the token is from a technical user, an error is thrown. * If the token is an access token, it is exchanged for an id token. * * Subsequent calls with access tokens will return a cached (but definitely not yet expired) id token. * The cache is configured by the `idTokenCache` service configuration property of this instance. * * @param {IdentityServiceToken} validatedToken - a validated token of a user for which the id token is required * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options * @returns {Promise<string>} - the id token as raw jwt string * @throws {Error} if the token is from a technical user */ async _getIdToken(validatedToken, options = {}) { if (validatedToken.subject === validatedToken.azp) { throw new ConfigurationError("ID tokens cannot be retrieved for technical users."); } if (!this.#isAccessToken(validatedToken)) { return validatedToken.jwt; } if (this.idTokenCache) { return this.#fetchIdTokenCached(validatedToken, options); } else { return this.#fetchIdToken(validatedToken, options); } } /** * Agreed-upon heuristic that derives whether the given token is an access token. * @param {IdentityServiceToken} validatedToken * @returns {boolean} true if the token is an access token, false otherwise */ #isAccessToken(validatedToken) { return validatedToken.audiences.length === 1 && validatedToken.audiences[0] !== validatedToken.azp; } /** * Retrieves an ID token for the given validated token, using cache when available. * @param {IdentityServiceToken} validatedToken * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options * @returns {Promise<string>} ID token */ async #fetchIdTokenCached(validatedToken, options = {}) { const cacheKey = createCacheKey({ ias_iss: validatedToken.issuer, app_tid: validatedToken.appTid || '', jti: validatedToken.payload.jti }); let idTokenJwt = this.idTokenCache.get(cacheKey); if (idTokenJwt) { const cachedToken = new IdentityServiceToken(idTokenJwt); if (cachedToken.remainingTime >= 300) { // tokens with a minimum remaining time of 5 minutes may be returned from cache return idTokenJwt; } else { // remove almost expired token from cache this.idTokenCache.set(cacheKey, null); } } idTokenJwt = await this.#fetchIdToken(validatedToken, options); this.idTokenCache.set(cacheKey, idTokenJwt); return idTokenJwt; } /** * Retrieves an ID token for the given validated token without using cache. * @param {IdentityServiceToken} validatedToken * @param {TokenFetchOptions & IdentityServiceTokenFetchOptions} [options] - custom token fetch options * @returns {Promise<string>} ID token */ async #fetchIdToken(validatedToken, options = {}) { const subscriberService = this.#getSubscriberIdentityService(validatedToken); const response = await subscriberService.fetchJwtBearerToken(validatedToken.jwt, { token_format: "jwt", refresh_expiry: 0, ...options }); return response.id_token; } /** * @internal * Gets the ID token cache for this IdentityService instance. * * @returns {import("../util/Types").Cache} The ID token cache. */ get idTokenCache() { if (this.#idTokenCache === undefined) { if (this.config.idTokenCache?.impl) { this.#idTokenCache = this.config.idTokenCache.impl; } else if (this.config.idTokenCache?.enabled !== false) { this.#idTokenCache = new LRUCache(this.config.idTokenCache.size); } else { this.#idTokenCache = null; } } return this.#idTokenCache; } /** @override */ buildTokenRequest(grant_type, options) { const request = super.buildTokenRequest(grant_type, options); if (options.resource) { // multiple resources need to be listed explicitly with multiple resource=<resource> key-value-pairs [options.resource] .flatMap(r => r) .forEach(resource => request.body.append("resource", resource)); } if (options.refresh_expiry != null) { request.body.append("refresh_expiry", options.refresh_expiry); } return request; } /** * Prepares the given formData and fetch options to use this service's client credentials for authentication. * Adds clientid, app_tid 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 * @override */ addClientAuthentication(request, options = {}) { super.addClientAuthentication(request, options); const app_tid = options.app_tid === undefined ? this.credentials.app_tid : options.app_tid; if (app_tid != null) { request.body.append("app_tid", app_tid); } } /** * @override * @inheritdoc */ async getTokenUrl(grant_type, options = {}) { const { correlationId } = options; const openidConfiguration = await this.getOpenIDConfiguration({ correlationId }); if (!openidConfiguration.grant_types_supported.includes(grant_type)) { throw new ConfigurationError(`This Identity Service instance does not support grant type ${grant_type} according to its openid-configuration.`); } return new URL(openidConfiguration.token_endpoint); } /** * Returns whether proof token validation has been enabled via the service's configuration. * @returns {Boolean} */ hasProofTokenEnabled() { return this.config.validation?.proofToken?.enabled; } /** * Returns whether x5t proof of token ownership validation has been enabled via the service's configuration. * @returns {Boolean} */ hasX5tEnabled() { return this.config.validation?.x5t?.enabled; } /** * Returns whether a proof token check has to be done for the given token. * The decision depends on the type of token. * Tokens with claim ias_api are App2App tokens for which a proof token check must not be done, even when enabled via the configuration. * @param {IdentityServiceToken} token */ #proofTokenCheckRequired(token) { return this.hasProofTokenEnabled() && token.payload.ias_apis == null; } /** * Returns a subscriber IdentityService instance based on the given token from the subscriber tenant. * The returned instance uses this instance's request configuration and client credentials but with the ias_iss URL from the token. * @param {IdentityServiceToken} token a token from which ias_iss is extracted to build credentials for the subscriber's IdentityService instance * @returns {IdentityService} IdentityService instance for the subscriber of the token */ #getSubscriberIdentityService(token) { return new IdentityService( { ...this.credentials, url: IdentityService.getSafeUrlFromTokenIssuer(token, this.credentials.domains), }, this.config ); } /** * Returns an issuer URL based on the issuer of the token if it can be succesfully validated against a list of trusted domains. * @param {IdentityServiceToken} token token from which issuer is extracted * @param {Array<string>} trustedDomains a list of trusted domains * @returns {String} URL of issuer if its domain is either a trusted domain or a subdomain of a trusted domain * @throws {UntrustedIssuerError} if issuer is empty, not trusted or not a valid URL */ static getSafeUrlFromTokenIssuer = function (token, trustedDomains = []) { const issuer = token?.issuer; if (!issuer) { throw new MissingIssuerError(token); } const issuerUrl = issuer.startsWith(HTTPS_SCHEME) ? issuer : `${HTTPS_SCHEME}${issuer}`; try { new URL(issuerUrl); } catch (e) { throw new InvalidIssuerError(token, e); } const issuerDomain = issuerUrl.substring(HTTPS_SCHEME.length); for (let d of trustedDomains) { const validSubdomainPattern = `^[a-zA-Z0-9-]{1,63}\\.${escapeStringForRegex(d)}$`; // a string that ends with .<trustedDomain> and contains 1-63 letters, digits or '-' before that for the subdomain if (issuerDomain === d || issuerDomain.match(new RegExp(validSubdomainPattern))) { return issuerUrl; } } throw new UntrustedIssuerError(token, issuer, trustedDomains); } /** * Validates that the client owning the given certificate is the owner of the token. * The validation is based on proof-of-posession via certificate binding of tokens as described in {@link https://datatracker.ietf.org/doc/html/rfc8705 RFC 8705}. * The validation is succesful if the token contains an base64url-encoded x5t thumbprint under claim {@link CNF_X5T_CLAIM cnf.x5t#S256} that matches the given certificate. * The client certificate against which the validation is performed, is typically extracted from the {@link FWD_CLIENT_CERT_HEADER x-forwarded-client-cert} request header where it is put by BTP after TLS termination. * @param {IdentityServiceToken} token * @param {X509Certificate} cert client certificate parsed as X509Certificate */ static validateTokenOwnership(token, cert) { const tokenX5t = token.payload.cnf?.[X5T_CNF_CLAIM]; if (!tokenX5t) { throw new X5tError(token, cert, "X5t validation failed because x5t thumbprint could not be found in token."); } let certificateX5t; try { certificateX5t = Buffer.from(cert.fingerprint256.replaceAll(":", ""), "hex").toString("base64url"); } catch (e) { throw new X5tError(token, cert, "x5t validation failed because x5t thumbprint could not be calculated from client certificate."); } if (tokenX5t !== certificateX5t) { throw new X5tError(token, cert, "x5t thumbprint did not match the thumbprint of the provided client certificate."); } } /** * Builds the configuration of this IdentityService based on the provided configuration and default values. * @param {ServiceConfig & IdentityServiceConfig} config * @override */ static buildServiceConfiguration(config) { config.idTokenCache ??= IdentityService.DEFAULT_ID_TOKEN_CACHE_CONFIG; if (config.idTokenCache.enabled === false || config.idTokenCache.size === 0) { config.idTokenCache = { enabled: false }; } else if (config.idTokenCache.impl) { config.idTokenCache = { impl: config.idTokenCache.impl, enabled: true }; } else { config.idTokenCache = { ...IdentityService.DEFAULT_ID_TOKEN_CACHE_CONFIG, ...config.idTokenCache, enabled: true }; } return config; } } module.exports = IdentityService;