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