UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

279 lines (236 loc) 11 kB
'use strict'; const Service = require('./Service'); const SecurityContext = require('../context/SecurityContext'); const XsuaaSecurityContext = require('../context/XsuaaSecurityContext'); const XsuaaToken = require('../token/XsuaaToken'); const Jwks = require('../jwks/Jwks'); const { jsonRequest } = require('../util/jsonRequest'); const { HTTPS_SCHEME, ZID_QUERY_PARAMETER, ZID_HEADER } = require('../util/constants'); const createCacheKey = require('../cache/createCacheKey'); const { ResponseError, WrongAudienceError } = require('../error'); const Token = require('../token/Token'); /** * @typedef {import('../util/Types').ServiceCredentials} ServiceCredentials * @typedef {import('../util/Types').XsuaaServiceCredentials} XsuaaServiceCredentials * @typedef {import('../util/Types').ServiceConfig} ServiceConfig * @typedef {import('../util/Types').SecurityContextConfig} SecurityContextConfig * @typedef {import('../util/Types').TokenFetchOptions} TokenFetchOptions * @typedef {import('../util/Types').XsuaaTokenFetchOptions} XsuaaTokenFetchOptions * @typedef {import('../util/Types').TokenFetchResponse} TokenFetchResponse * @typedef {import('../util/Types').RefreshableTokenFetchResponse} RefreshableTokenFetchResponse * @typedef {import('../util/Types').GrantType} GrantType */ /** * New SAP BTP applications should start with SAP Identity Services instead of XSUAA! See README for details.\ * This {@link Service} class is constructed from XSUAA credentials to provide an API with selected functionality against that XSUAA service instance, e.g. token validation and token fetches. */ class XsuaaService extends Service { /** @type {Object.<string, string>} */ #endpoints; /** @type {string} */ #jwksBaseUrl; // base URL from which the JWKS is fetched /** * @param {ServiceCredentials & XsuaaServiceCredentials} credentials * @param {ServiceConfig} [serviceConfig={}] */ constructor(credentials, serviceConfig) { super(credentials, serviceConfig); } /** * @internal * @override * 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 || super.endpoints.oidc_info, jwks: this.config.endpoints?.jwks || "/token_keys", token: this.config.endpoints?.token || "/oauth/token", } } return this.#endpoints; } /** * @overrides * @inheritdoc */ acceptsTokenAudience(token) { this.validateCredentials("validate token audience", "clientid", "xsappname"); if (!(token instanceof XsuaaToken)) { // cast to XsuaaToken, so token.scopes getter exists for the checks below token = new XsuaaToken(null, { header: token.header, payload: token.payload }); } // XSUAA tokens with grant_type === 'user_token' might not have audiences filled, so a fallback to scopes is needed let audiencesToConsider; if (token.audiences?.length > 0) { audiencesToConsider = [...token.audiences]; } else if (token.scopes) { audiencesToConsider = [...token.scopes]; } else { audiencesToConsider = []; } if (token.payload.cid) { audiencesToConsider.push(token.payload.cid); } return audiencesToConsider.some(a => a === this.credentials.clientid || a.startsWith(`${this.credentials.clientid}.`)) || audiencesToConsider.some(a => a === this.credentials.xsappname || a.startsWith(`${this.credentials.xsappname}.`)) // broker plan logic below || this.credentials.clientid.includes("!b") && audiencesToConsider.some(a => a.endsWith(`|${this.credentials.xsappname}`)); } /** * @override * @param {String|XsuaaToken} token as JWT or XsuaaToken object * @param {SecurityContextConfig} contextConfig * @returns {Promise<XsuaaSecurityContext} */ async createSecurityContext(token, contextConfig = {}) { if (typeof token === "string") { token = new XsuaaToken(token); } else if (token instanceof Token && !(token instanceof XsuaaToken)) { token = new XsuaaToken(token.jwt, { header: token.header, payload: token.payload }); } SecurityContext.buildContextConfig(contextConfig); if (contextConfig.skipValidation !== true && this.config.validation.enabled !== false) { await this.validateToken(token, contextConfig); } let ctx = new XsuaaSecurityContext(this, token, contextConfig); for (let extension of this.contextExtensions) { ctx = await extension.extendSecurityContext(ctx) ?? ctx; } return ctx; } async getJwks(token, contextConfig) { const jwksParams = { zid: token.zid } const keyParts = { url: this.jwksBaseUrl, ...jwksParams }; const cacheKey = createCacheKey(keyParts); const buildJwksRequest = () => { return async (correlationId) => { const jwksResponse = await this.fetchJwks(jwksParams, correlationId); return new Jwks(jwksResponse.keys); } } let jwks; try { jwks = await this.jwksCache.getOrRequest(cacheKey, buildJwksRequest, { correlationId: contextConfig.correlationId }); } catch (error) { if (error instanceof ResponseError && error.responseCode === 400) { /** * 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 XSUAA server responded with 400: '${error.responseText}'. This indicates that the token should not be accepted by this service.`); } throw error; } return jwks; } /** * @internal * Returns the base URL (https protocol + uaadomain from the credentials) that can be used for JWKS fetches. * @returns {String} base URL for JWKS fetches */ get jwksBaseUrl() { if (!this.#jwksBaseUrl) { this.validateCredentials("fetch JWKS", "uaadomain"); const { uaadomain } = this.credentials; if (uaadomain.startsWith(HTTPS_SCHEME)) { this.#jwksBaseUrl = uaadomain; } else { this.#jwksBaseUrl = `${HTTPS_SCHEME}${uaadomain}`; } } return this.#jwksBaseUrl; } async fetchJwks(jwksParams, correlationId) { const jwksUrl = new URL(this.jwksBaseUrl + this.endpoints.jwks); if (jwksParams.zid) { jwksUrl.searchParams.append(ZID_QUERY_PARAMETER, jwksParams.zid); } const request = this.buildRequest({ method: 'GET', }); return jsonRequest(jwksUrl, request, { requestName: `${this.constructor.name}.fetchJwks`, correlationId }); } // 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 & XsuaaTokenFetchOptions} options * @returns {Promise<TokenFetchResponse>} response */ 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 & XsuaaTokenFetchOptions} options * @returns {Promise<TokenFetchResponse & RefreshableTokenFetchResponse>} response */ 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 & XsuaaTokenFetchOptions} options * @returns {Promise<TokenFetchResponse & RefreshableTokenFetchResponse>} response */ async fetchJwtBearerToken(assertion, options = {}) { return super.fetchJwtBearerToken(assertion, options); } /** @override */ buildTokenRequest(grant_type, options) { const request = super.buildTokenRequest(grant_type, options); if (options.scope) { request.body.append("scope", options.scope); } if (options.authorities) { request.body.append("authorities", JSON.stringify({ az_attr: options.authorities })); } if (options.zid) { request.headers ??= {}; request.headers[ZID_HEADER] = options.zid; } return request; } /** * Determines the URL that can be used for fetching tokens from this service, optionally adjusted for a tenant in the same landscape. * If a zone ID is provided in the options without subdomain, the URL should use the uaadomain without the provider's subdomain to avoid * correlation of the request with the provider's tenant on server side, e.g. for logging and rate-limiting. * * @override * @inheritdoc * @param {GrantType} grant_type * @param {String} options.tenant */ async getTokenUrl(grant_type, options = {}) { const { tenant, zid } = options; let baseUrl; if (tenant || zid) { this.validateCredentials("build token fetch URL for custom tenant subdomain or zone id", "uaadomain"); const uaaDomain = this.credentials.certificate ? this.credentials.uaadomain.replace("authentication.", "authentication.cert.") : this.credentials.uaadomain; if(tenant && zid == null) { baseUrl = `${HTTPS_SCHEME}${tenant}.${uaaDomain}`; } else { baseUrl = `${HTTPS_SCHEME}${uaaDomain}`; } } else { if (this.credentials.certificate) { this.validateCredentials("fetch token via certificate authentication", "certurl"); baseUrl = this.credentials.certurl; } else { this.validateCredentials("fetch token via client secret authentication", "url"); baseUrl = this.credentials.url; } } return new URL(this.endpoints.token, baseUrl); } } module.exports = XsuaaService;