diff --git a/.changeset/slow-cars-lie.md b/.changeset/slow-cars-lie.md new file mode 100644 index 0000000000..a4608714d7 --- /dev/null +++ b/.changeset/slow-cars-lie.md @@ -0,0 +1,5 @@ +--- +'@sap-cloud-sdk/connectivity': minor +--- + +[New Functionality] Support IAS (App-to-App) authentication. Use `transformServiceBindingToDestination()` function or `getDestinationFromServiceBinding()` function to create a destination targeting an IAS application. diff --git a/packages/connectivity/src/http-agent/http-agent.spec.ts b/packages/connectivity/src/http-agent/http-agent.spec.ts index 07e1458175..47d58b4b81 100644 --- a/packages/connectivity/src/http-agent/http-agent.spec.ts +++ b/packages/connectivity/src/http-agent/http-agent.spec.ts @@ -358,6 +358,35 @@ describe('getAgentConfig', () => { expect(actual.passphrase).not.toBeDefined(); expect(cacheSpy).toHaveBeenCalledTimes(1); }); + + it('logs a warning when both mtls is enabled and mtlsKeyPair is provided', async () => { + process.env.CF_INSTANCE_CERT = 'cf-crypto/cf-cert'; + process.env.CF_INSTANCE_KEY = 'cf-crypto/cf-key'; + + const destination: HttpDestination = { + url: 'https://example.com', + name: 'test-destination', + mtls: true, + mtlsKeyPair: { + cert: 'ias-cert', + key: 'ias-key' + } + }; + + const logger = createLogger('http-agent'); + const warnSpy = jest.spyOn(logger, 'warn'); + + const actual = (await getAgentConfig(destination))['httpsAgent'] + .options; + + expect(warnSpy).toHaveBeenCalledWith( + "Destination test-destination has both 'mtlsKeyPair' (used by IAS) and 'mtls' (to use certs from cf) enabled. The 'mtlsKeyPair' will be used." + ); + expect(actual.cert).toEqual('ias-cert'); + expect(actual.key).toEqual('ias-key'); + + warnSpy.mockRestore(); + }); }); it('returns an object with key "httpsAgent" which is missing mTLS options when mtls is set to true but env variables do not include cert & key', async () => { diff --git a/packages/connectivity/src/http-agent/http-agent.ts b/packages/connectivity/src/http-agent/http-agent.ts index 5588637a13..f9350c10d9 100644 --- a/packages/connectivity/src/http-agent/http-agent.ts +++ b/packages/connectivity/src/http-agent/http-agent.ts @@ -117,7 +117,7 @@ function getKeyStoreOptions(destination: Destination): if ( // Only add certificates, when using ClientCertificateAuthentication (https://github.com/SAP/cloud-sdk-js/issues/3544) destination.authentication === 'ClientCertificateAuthentication' && - !mtlsIsEnabled(destination) && + !(mtlsIsEnabled(destination) || destination.mtlsKeyPair) && destination.keyStoreName ) { const certificate = selectCertificate(destination); @@ -213,6 +213,17 @@ async function getMtlsOptions( } has mTLS enabled, but the required Cloud Foundry environment variables (CF_INSTANCE_CERT and CF_INSTANCE_KEY) are not defined. Note that 'inferMtls' only works on Cloud Foundry.` ); } + if (destination.mtlsKeyPair) { + if (mtlsIsEnabled(destination)) { + logger.warn( + `Destination ${ + destination.name ? destination.name : '' + } has both 'mtlsKeyPair' (used by IAS) and 'mtls' (to use certs from cf) enabled. The 'mtlsKeyPair' will be used.` + ); + } + + return destination.mtlsKeyPair; + } if (mtlsIsEnabled(destination)) { if (registerDestinationCache.mtls.useMtlsCache) { return registerDestinationCache.mtls.getMtlsOptions(); diff --git a/packages/connectivity/src/index.ts b/packages/connectivity/src/index.ts index 013286cb89..1ca8bdbf23 100644 --- a/packages/connectivity/src/index.ts +++ b/packages/connectivity/src/index.ts @@ -58,7 +58,12 @@ export type { DestinationJson, DestinationsByType, DestinationFromServiceBindingOptions, - ServiceBindingTransformOptions + ServiceBindingTransformOptions, + IasOptions, + IasOptionsBase, + IasOptionsBusinessUser, + IasOptionsTechnicalUser, + IasResource } from './scp-cf'; export type { diff --git a/packages/connectivity/src/scp-cf/client-credentials-token-cache.spec.ts b/packages/connectivity/src/scp-cf/client-credentials-token-cache.spec.ts index a9184bbf3d..083171b81a 100644 --- a/packages/connectivity/src/scp-cf/client-credentials-token-cache.spec.ts +++ b/packages/connectivity/src/scp-cf/client-credentials-token-cache.spec.ts @@ -1,5 +1,8 @@ import { createLogger } from '@sap-cloud-sdk/util'; -import { clientCredentialsTokenCache } from './client-credentials-token-cache'; +import { + clientCredentialsTokenCache, + getIasCacheKey +} from './client-credentials-token-cache'; const oneHourInSeconds = 60 * 60; @@ -87,4 +90,217 @@ describe('ClientCredentialsTokenCache', () => { 'Cannot create cache key for client credentials token cache. The given client ID is undefined.' ); }); + + describe('IAS resource parameter support', () => { + const validToken = { + access_token: '1234567890', + token_type: 'Bearer', + expires_in: oneHourInSeconds * 3, + jti: '', + scope: '' + }; + const iasTokenCacheData = { + iasInstance: 'subscriber-tenant', + clientId: 'clientid', + resource: { name: 'my-app' } + }; + + beforeEach(() => { + clientCredentialsTokenCache.clear(); + }); + + it('should cache and retrieve token with resource name', () => { + clientCredentialsTokenCache.cacheIasToken(iasTokenCacheData, validToken); + + const cached = clientCredentialsTokenCache.getTokenIas(iasTokenCacheData); + + expect(cached).toEqual(validToken); + }); + + it('should cache and retrieve token with resource clientId', () => { + const resource = { providerClientId: 'resource-client-123' }; + + clientCredentialsTokenCache.cacheIasToken( + { + ...iasTokenCacheData, + resource + }, + validToken + ); + + const cached = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource + }); + + expect(cached).toEqual(validToken); + }); + + it('should cache and retrieve token with resource clientId and tenantId', () => { + const resource = { + providerClientId: 'resource-client-123', + providerTenantId: 'tenant-456' + }; + + clientCredentialsTokenCache.cacheIasToken( + { + ...iasTokenCacheData, + resource + }, + validToken + ); + + const cached = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource + }); + + expect(cached).toEqual(validToken); + }); + + it('should isolate cache by resource name', () => { + const resource1 = { name: 'app-1' }; + const resource2 = { name: 'app-2' }; + + clientCredentialsTokenCache.cacheIasToken( + { + ...iasTokenCacheData, + resource: resource1 + }, + validToken + ); + + const cached1 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource: resource1 + }); + const cached2 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource: resource2 + }); + + expect(cached1).toEqual(validToken); + expect(cached2).toBeUndefined(); + }); + + it('should isolate cache by resource providerClientId', () => { + const resource1 = { providerClientId: 'client-1' }; + const resource2 = { providerClientId: 'client-2' }; + + clientCredentialsTokenCache.cacheIasToken( + { + ...iasTokenCacheData, + resource: resource1 + }, + validToken + ); + + const cached1 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource: resource1 + }); + const cached2 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + resource: resource2 + }); + + expect(cached1).toEqual(validToken); + expect(cached2).toBeUndefined(); + }); + + it('should generate correct cache key with resource name', () => { + const key = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id', + resource: { name: 'my-app' } + }); + expect(key).toBe('tenant-123::client-id:name=my-app'); + }); + + it('should generate correct cache key with resource clientId only', () => { + const key = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id', + resource: { + providerClientId: 'resource-client-123' + } + }); + expect(key).toBe( + 'tenant-123::client-id:provider-clientId=resource-client-123' + ); + }); + + it('should generate correct cache key with resource clientId and tenantId', () => { + const key = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id', + resource: { + providerClientId: 'resource-client-123', + providerTenantId: 'tenant-456' + } + }); + expect(key).toBe( + 'tenant-123::client-id:provider-clientId=resource-client-123:provider-tenantId=tenant-456' + ); + }); + + it('should generate cache key without resource when not provided', () => { + const key = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id' + }); + expect(key).toBe('tenant-123::client-id'); + }); + + it('should isolate cache by appTid', () => { + clientCredentialsTokenCache.cacheIasToken( + { + ...iasTokenCacheData, + appTid: 'tenant-123' + }, + validToken + ); + + const cached1 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + appTid: 'tenant-123' + }); + const cached2 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData, + appTid: 'tenant-456' + }); + const cached3 = clientCredentialsTokenCache.getTokenIas({ + ...iasTokenCacheData + // No appTid + }); + + expect(cached1).toEqual(validToken); + expect(cached2).toBeUndefined(); + expect(cached3).toBeUndefined(); + }); + + it('should generate correct cache key with appTid', () => { + const key = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id', + appTid: 'app-tenant-456' + }); + expect(key).toBe('tenant-123:app-tenant-456:client-id'); + }); + + it('should generate cache key with double colon when appTid is undefined', () => { + const key1 = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id', + appTid: undefined + }); + const key2 = getIasCacheKey({ + iasInstance: 'tenant-123', + clientId: 'client-id' + }); + // Both should produce the same key with double colon + expect(key1).toBe('tenant-123::client-id'); + expect(key2).toBe('tenant-123::client-id'); + }); + }); }); diff --git a/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts b/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts index cd146bf4c2..b724c1302d 100644 --- a/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts +++ b/packages/connectivity/src/scp-cf/client-credentials-token-cache.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sap-cloud-sdk/util'; import { Cache } from './cache'; import type { ClientCredentialsResponse } from './xsuaa-service-types'; +import type { IasResource } from './destination'; const logger = createLogger({ package: 'connectivity', @@ -28,17 +29,59 @@ const ClientCredentialsTokenCache = ( : undefined }); }, + + getTokenIas: ( + data: IasClientCredentialsCacheKeyData + ): ClientCredentialsResponse | undefined => cache.get(getIasCacheKey(data)), + + cacheIasToken: ( + data: IasClientCredentialsCacheKeyData, + tokenResponse: ClientCredentialsResponse + ): void => { + cache.set(getIasCacheKey(data), { + entry: tokenResponse, + expires: tokenResponse.expires_in + ? Date.now() + tokenResponse.expires_in * 1000 + : undefined + }); + }, + clear: (): void => { cache.clear(); }, getCacheInstance: () => cache }); +/** + * Normalizes the IAS resource parameter to a consistent string format for cache key. + * @param resource - The resource parameter from iasOptions. + * @returns Normalized resource string or empty string if not provided. + * @internal + */ +function normalizeResource( + resource?: IasResource +): [] | [string] | [string, string] { + if (!resource) { + return []; + } + if ('name' in resource) { + return [`name=${resource.name}`]; + } + + const normalized: [string] | [string, string] = [ + `provider-clientId=${resource.providerClientId}` + ]; + if (resource.providerTenantId) { + normalized.push(`provider-tenantId=${resource.providerTenantId}`); + } + return normalized; +} + /** * * @internal * @param tenantId - The ID of the tenant to cache the token for. - * @param clientId - ClientId to fetch the token - * @returns the token + * @param clientId - ClientId to fetch the token. + * @returns The cache key. */ export function getCacheKey( tenantId: string | undefined, @@ -59,6 +102,63 @@ export function getCacheKey( return [tenantId, clientId].join(':'); } +/** + * An interface for data the is used for IAS client credentials cache keys + * @internal + */ +interface IasClientCredentialsCacheKeyData { + /** + * The hostname of the IAS instance. + * @example tenant.accounts400.ondemand.com + */ + iasInstance: string; + /** + * The client credentials client ID. + */ + clientId: string; + /** + * The BTP instanced ID supplied with the token request. + */ + appTid?: string | undefined; + resource?: IasResource | undefined; +} + +/** * + * @internal + * @param data.iasInstance - The IAS instance (tenant) hostname the token is fetched from. + * @param data.appTid - The BTP instance (tenant) id (App-Tid) + * @param data.clientId - ClientId to fetch the token. + * @param data.resource - The App-To-App resource the token is scoped for. + * @returns The cache key. + */ +export function getIasCacheKey( + data: IasClientCredentialsCacheKeyData +): string | undefined { + const { iasInstance, appTid, clientId, resource } = data; + if (!iasInstance) { + logger.warn( + 'Cannot create cache key for client credentials token cache. The given IAS instance hostname is undefined.' + ); + return; + } + + if (!clientId) { + logger.warn( + 'Cannot create cache key for client credentials token cache. The given client ID is undefined.' + ); + return; + } + + const output = [ + iasInstance, + appTid || '', + clientId, + ...normalizeResource(resource) + ]; + + return output.join(':'); +} + /** * @internal */ diff --git a/packages/connectivity/src/scp-cf/destination/destination-accessor-provider-subscriber-lookup.spec.ts b/packages/connectivity/src/scp-cf/destination/destination-accessor-provider-subscriber-lookup.spec.ts index c354a4fd47..072cef2e1a 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-accessor-provider-subscriber-lookup.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-accessor-provider-subscriber-lookup.spec.ts @@ -27,6 +27,7 @@ import { import { mockServiceToken } from '../../../../../test-resources/test/test-util/token-accessor-mocks'; import * as tokenAccessor from '../token-accessor'; import { decodeJwt } from '../jwt'; +import { identityServicesCache } from '../environment-accessor'; import { parseDestination } from './destination'; import { getAllDestinationsFromDestinationService, @@ -171,6 +172,7 @@ describe('JWT type and selection strategies', () => { afterEach(() => { nock.cleanAll(); jest.clearAllMocks(); + identityServicesCache.clear(); }); describe('user token', () => { diff --git a/packages/connectivity/src/scp-cf/destination/destination-accessor-types.ts b/packages/connectivity/src/scp-cf/destination/destination-accessor-types.ts index a24b8df8c5..e5979a372d 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-accessor-types.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-accessor-types.ts @@ -51,6 +51,7 @@ export interface DestinationAccessorOptions { * ATTENTION: The property is mandatory in the following cases: * - User-dependent authentication flow is used, e.g., `OAuth2UserTokenExchange`, `OAuth2JWTBearer`, `OAuth2SAMLBearerAssertion`, `SAMLAssertion` or `PrincipalPropagation`. * - Multi-tenant scenarios with destinations maintained in the subscriber account. This case is implied if the `selectionStrategy` is set to `alwaysSubscriber`. + * - IAS business user authentication (OAuth2JWTBearer). In this case, the JWT is used as the assertion for the IAS token exchange. The authentication type is set via the `iasOptions` parameter when used with {@link getDestinationFromServiceBinding}. */ jwt?: string; diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-vcap.spec.ts b/packages/connectivity/src/scp-cf/destination/destination-from-vcap.spec.ts index 8503715fdb..5a98883ce8 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-from-vcap.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-vcap.spec.ts @@ -4,6 +4,7 @@ import { signedJwt } from '../../../../../test-resources/test/test-util'; import * as xsuaaService from '../xsuaa-service'; +import * as identityService from '../identity-service'; import { clientCredentialsTokenCache } from '../client-credentials-token-cache'; import { getDestination } from './destination-accessor'; import { getDestinationFromServiceBinding } from './destination-from-vcap'; @@ -267,6 +268,129 @@ describe('vcap-service-destination', () => { expect(destination?.authTokens?.[0]).toMatchObject({ value: jwt }); }); + + it('forwards iasOptions to the transform function', async () => { + const iasOptions = { + resource: { providerClientId: 'test-client-id' } + }; + const serviceBindingTransformFn = jest.fn(async (service: Service) => ({ + url: service.credentials.sys + })); + + await getDestinationFromServiceBinding({ + destinationName: 'my-custom-service', + serviceBindingTransformFn, + iasOptions + }); + + expect(serviceBindingTransformFn).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ iasOptions }) + ); + }); + + describe('IAS service binding', () => { + function mockIasClientCredentialsToken(aud: string) { + const token = signedJwt({ jti: 'some-jti', ias_apis: [], aud }); + const spy = jest.spyOn(identityService, 'getIasToken').mockResolvedValue({ + access_token: token, + expires_in: 3600, + token_type: 'bearer', + aud, + scope: '' as const, + ias_apis: [], + jti: 'some-jti' + }); + return { token, spy }; + } + + it('creates a destination for IAS service with App2App authentication using resource name', async () => { + const { token: mockToken, spy: getIasTokenSpy } = + mockIasClientCredentialsToken('target-app-name'); + + const destination = await getDestinationFromServiceBinding({ + destinationName: 'my-identity-service', + iasOptions: { + resource: { name: 'target-app-name' }, + targetUrl: 'https://target-app.example.com' + } + }); + + expect(destination).toMatchObject({ + url: 'https://target-app.example.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials', + authTokens: [ + expect.objectContaining({ + value: mockToken, + type: 'bearer' + }) + ] + }); + + expect(getIasTokenSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-identity-service', + label: 'identity' + }), + expect.objectContaining({ + resource: { name: 'target-app-name' } + }) + ); + }); + + it('creates a destination for IAS service with App2App authentication using providerClientId', async () => { + const { token: mockToken, spy: getIasTokenSpy } = + mockIasClientCredentialsToken('target-app-name'); + + const destination = await getDestinationFromServiceBinding({ + destinationName: 'my-identity-service', + iasOptions: { + resource: { + providerClientId: 'provider-client-id', + providerTenantId: 'provider-tenant-id' + }, + targetUrl: 'https://target-app.example.com' + } + }); + + expect(destination).toMatchObject({ + url: 'https://target-app.example.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials', + authTokens: [ + expect.objectContaining({ + value: mockToken + }) + ] + }); + + expect(getIasTokenSpy).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + resource: { + providerClientId: 'provider-client-id', + providerTenantId: 'provider-tenant-id' + } + }) + ); + }); + + it('uses IAS service URL when targetUrl is not provided', async () => { + mockIasClientCredentialsToken('my-identity-service'); + + const destination = await getDestinationFromServiceBinding({ + destinationName: 'my-identity-service', + iasOptions: { + resource: { name: 'target-app-name' } + } + }); + + expect(destination.url).toBe( + 'https://my-identity-service.accounts.ondemand.com' + ); + }); + }); }); function mockServiceBindings() { @@ -391,5 +515,17 @@ const serviceBindings = { apiurl: 'https://api.authentication.sap.hana.ondemand.com' } } + ], + identity: [ + { + label: 'identity', + name: 'my-identity-service', + tags: ['identity'], + credentials: { + clientid: 'clientIdIdentity', + clientsecret: 'PASSWORD', + url: 'https://my-identity-service.accounts.ondemand.com' + } + } ] }; diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-vcap.ts b/packages/connectivity/src/scp-cf/destination/destination-from-vcap.ts index 6549608b59..b53b42f3b5 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-from-vcap.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-vcap.ts @@ -13,6 +13,7 @@ import type { Destination } from './destination-service-types'; import type { CachingOptions } from '../cache'; import type { Service } from '../environment-accessor'; import type { JwtPayload } from '../jsonwebtoken-type'; +import type { IasOptions } from './ias-types'; const logger = createLogger({ package: 'connectivity', @@ -31,7 +32,7 @@ export async function getDestinationFromServiceBinding( DestinationFetchOptions, 'jwt' | 'iss' | 'useCache' | 'destinationName' > & - DestinationFromServiceBindingOptions + DestinationFromServiceBindingOptions & { iasOptions?: IasOptions } ): Promise { const decodedJwt = options.iss ? { iss: options.iss } @@ -39,7 +40,17 @@ export async function getDestinationFromServiceBinding( ? decodeJwt(options.jwt) : undefined; - const retrievalOptions = { ...options, jwt: decodedJwt }; + // If using business user authentication with IAS and no assertion provided, use the JWT from options + let iasOptions = options.iasOptions; + if ( + iasOptions?.authenticationType === 'OAuth2JWTBearer' && + options.jwt && + !iasOptions.assertion + ) { + iasOptions = { ...iasOptions, assertion: options.jwt }; + } + + const retrievalOptions = { ...options, jwt: decodedJwt, iasOptions }; const destination = await retrieveDestination(retrievalOptions); const destWithProxy = @@ -60,14 +71,17 @@ async function retrieveDestination({ useCache, jwt, destinationName, + iasOptions, serviceBindingTransformFn }: Pick & { jwt?: JwtPayload; + iasOptions?: IasOptions; } & DestinationFromServiceBindingOptions) { const service = getServiceBindingByInstanceName(destinationName); const destination = await (serviceBindingTransformFn || transform)(service, { useCache, - jwt + jwt, + ...(iasOptions ? { iasOptions } : {}) }); return { name: destinationName, ...destination }; @@ -91,6 +105,10 @@ export type ServiceBindingTransformOptions = { * The JWT payload used to fetch destinations. */ jwt?: JwtPayload; + /** + * The options for IAS token retrieval. + */ + iasOptions?: IasOptions; } & CachingOptions; /** diff --git a/packages/connectivity/src/scp-cf/destination/destination-service-types.ts b/packages/connectivity/src/scp-cf/destination/destination-service-types.ts index 71ca05f594..6993d46df8 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-service-types.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-service-types.ts @@ -1,3 +1,4 @@ +import type { MtlsOptions } from '../../internal'; import type { CachingOptions } from '../cache'; import type { ProxyConfiguration } from '../connectivity-service-types'; import type { IsolationStrategy } from './destination-cache'; @@ -169,6 +170,12 @@ export interface Destination { * will be automatically used for TLS secured HTTP requests. */ mtls?: boolean; + + /** + * MTLS key pair consisting of certificate and private key in PEM format. + * This field is used to authenticate the destination using mTLS. + */ + mtlsKeyPair?: MtlsOptions; } /** diff --git a/packages/connectivity/src/scp-cf/destination/ias-types.ts b/packages/connectivity/src/scp-cf/destination/ias-types.ts new file mode 100644 index 0000000000..f3dbcb13fb --- /dev/null +++ b/packages/connectivity/src/scp-cf/destination/ias-types.ts @@ -0,0 +1,105 @@ +import type { Xor } from '@sap-cloud-sdk/util'; +import type { AuthenticationType } from './destination-service-types'; +import type { IdentityService } from '@sap/xssec'; + +/** + * The application resource for which the token is requested for App-to-App communication. + * The token will only be usable to call the requested application. + * Either provide the app name (common case) or the provider client ID + * and tenant ID. + */ +export type IasResource = Xor< + { + /** + * The name of the application resource. + */ + name: string; + }, + { + /** + * The client ID of the application resource. + */ + providerClientId: string; + /** + * The tenant ID of the application resource. + */ + providerTenantId?: string; + } +>; + +/** + * Base options shared by all IAS authentication modes. + */ +export interface IasOptionsBase { + /** + * The target URL of the destination that the IAS token is requested for. + * It is recommended to provide this for App-to-App communication (when resource parameter is used), + * otherwise the destination will point to the identity service URL from the service binding. + * @default The (identity service) URL from the service binding. + */ + targetUrl?: string; + /** + * The application resource(s) for which the token is requested. + * The token will only be usable to call the requested application(s). + * Either provide the app name (common case) or the provider client ID + * and tenant ID. + * + * It is recommended to also provide the `targetUrl` parameter, otherwise + * the destination will point to the identity service URL from the service binding, + * instead of the actual target application. + */ + resource?: IasResource; + /** + * The consumer (BTP) tenant ID of the application. + * May be required for multi-tenant communication. + */ + appTid?: string; + /** + * Additional parameters for the token request to be forwarded to the token fetching function + * of `@sap/xssec`. + */ + extraParams?: Omit< + IdentityService.TokenFetchOptions & + IdentityService.IdentityServiceTokenFetchOptions, + 'token_format' | 'resource' | 'app_tid' + >; +} + +/** + * IAS options for technical user authentication (client credentials). + */ +export interface IasOptionsTechnicalUser extends IasOptionsBase { + /** + * Authentication type. Use 'OAuth2ClientCredentials' for technical users. + * @defaultValue 'OAuth2ClientCredentials' + */ + authenticationType?: Extract; + /** + * Assertion not used for technical user authentication. + */ + assertion?: never; + /** + * Specifies whether the token request is made in the context of the current tenant or the provider tenant. + * @defaultValue 'current-tenant' + */ + requestAs?: 'current-tenant' | 'provider-tenant'; +} + +/** + * IAS options for business user authentication (JWT bearer). + */ +export interface IasOptionsBusinessUser extends IasOptionsBase { + /** + * Authentication type. Use 'OAuth2JWTBearer' for business user authentication. + */ + authenticationType: Extract; + /** + * The JWT assertion string to use for business user authentication. + */ + assertion: string; +} + +/** + * Options for IAS token retrieval with type-safe authentication type/assertion relationship. + */ +export type IasOptions = IasOptionsTechnicalUser | IasOptionsBusinessUser; diff --git a/packages/connectivity/src/scp-cf/destination/index.ts b/packages/connectivity/src/scp-cf/destination/index.ts index d5d6c17159..24b98cb764 100644 --- a/packages/connectivity/src/scp-cf/destination/index.ts +++ b/packages/connectivity/src/scp-cf/destination/index.ts @@ -14,5 +14,6 @@ export * from './forward-auth-token'; export * from './get-subscriber-token'; export * from './get-provider-token'; export * from './http-proxy-util'; +export * from './ias-types'; export * from './service-binding-to-destination'; export * from './register-destination-cache'; diff --git a/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.spec.ts b/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.spec.ts index 5fb5251973..1d4b7578fc 100644 --- a/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.spec.ts @@ -1,12 +1,40 @@ -import { serviceToken } from '../token-accessor'; import { resolveServiceBinding } from '../environment-accessor/service-bindings'; +import { getIasToken } from '../identity-service'; +import { clientCredentialsTokenCache } from '../client-credentials-token-cache'; import { decodeJwt } from '../jwt'; +import { serviceToken } from '../token-accessor'; import { transformServiceBindingToClientCredentialsDestination, transformServiceBindingToDestination } from './service-binding-to-destination'; +jest.mock('../identity-service', () => ({ + getIasToken: jest.fn() +})); + +jest.mock('../token-accessor', () => ({ + serviceToken: jest.fn() +})); + +jest.mock('../jwt', () => ({ + decodeJwt: jest.fn() +})); + const services = { + identity: [ + { + name: 'my-identity-service', + label: 'identity', + tags: ['identity'], + credentials: { + url: 'https://tenant.accounts.ondemand.com', + clientid: 'identity-clientid', + certificate: + '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', + key: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----' + } + } + ], destination: [ { name: 'my-destination-service1', @@ -97,21 +125,25 @@ const services = { ] }; -jest.mock('../token-accessor', () => ({ - serviceToken: jest.fn() -})); - -jest.mock('../jwt', () => ({ - decodeJwt: jest.fn() -})); - describe('service binding to destination', () => { beforeAll(() => { (serviceToken as jest.Mock).mockResolvedValue('access-token'); + (getIasToken as jest.Mock).mockResolvedValue({ + access_token: 'ias-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid', + jti: 'mock-jti' + }); (decodeJwt as jest.Mock).mockReturnValue({ exp: 1596549600 }); process.env.VCAP_SERVICES = JSON.stringify(services); }); + beforeEach(() => { + clientCredentialsTokenCache.clear(); + jest.clearAllMocks(); + }); + afterAll(() => { jest.clearAllMocks(); delete process.env.VCAP_SERVICES; @@ -255,4 +287,649 @@ describe('service binding to destination', () => { }) ); }); + + it('transforms identity (IAS) service binding', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity') + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials', + authTokens: expect.arrayContaining([ + expect.objectContaining({ + value: 'ias-access-token', + type: 'bearer' + }) + ]) + }) + ); + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity', + name: 'my-identity-service' + }), + expect.objectContaining({}) + ); + }); + + it('transforms identity (IAS) service binding for JWT bearer authentication', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token' + } + } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2JWTBearer', + authTokens: expect.arrayContaining([ + expect.objectContaining({ + value: 'ias-access-token', + type: 'bearer' + }) + ]) + }) + ); + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity' + }), + expect.objectContaining({ + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token' + }) + ); + }); + + it('transforms identity (IAS) service binding with appName parameter', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { iasOptions: { resource: { name: 'my-app' } } } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials' + }) + ); + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity' + }), + expect.objectContaining({ + resource: { name: 'my-app' } + }) + ); + }); + + it('transforms identity (IAS) service binding with custom targetUrl', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { iasOptions: { targetUrl: 'https://custom-target.example.com' } } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://custom-target.example.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials' + }) + ); + }); + + it('transforms identity (IAS) service binding and includes mTLS cert/key in destination', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity') + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2ClientCredentials', + mtlsKeyPair: { + cert: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', + key: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----' + }, + authTokens: expect.arrayContaining([ + expect.objectContaining({ + value: 'ias-access-token', + type: 'bearer' + }) + ]) + }) + ); + }); + + it('transforms identity (IAS) service binding for JWT bearer with mTLS cert/key', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token' + } + } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2JWTBearer', + mtlsKeyPair: { + cert: '-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----', + key: '-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----' + }, + authTokens: expect.arrayContaining([ + expect.objectContaining({ + value: 'ias-access-token', + type: 'bearer' + }) + ]) + }) + ); + }); + + it('transforms identity (IAS) service binding for JWT bearer with custom targetUrl', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token', + targetUrl: 'https://custom-target.example.com' + } + } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://custom-target.example.com', + name: 'my-identity-service', + authentication: 'OAuth2JWTBearer' + }) + ); + }); + + it('transforms identity (IAS) service binding for JWT bearer with resource parameter', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token', + resource: { name: 'my-target-app' } + } + } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2JWTBearer' + }) + ); + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity' + }), + expect.objectContaining({ + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token', + resource: { name: 'my-target-app' } + }) + ); + }); + + it('transforms identity (IAS) service binding for JWT bearer with resource providerClientId', async () => { + const destination = await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token', + resource: { + providerClientId: 'target-client-id', + providerTenantId: 'target-tenant-id' + } + } + } + ); + expect(destination).toEqual( + expect.objectContaining({ + url: 'https://tenant.accounts.ondemand.com', + name: 'my-identity-service', + authentication: 'OAuth2JWTBearer' + }) + ); + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity' + }), + expect.objectContaining({ + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token', + resource: { + providerClientId: 'target-client-id', + providerTenantId: 'target-tenant-id' + } + }) + ); + }); + + describe('transformIasBindingToDestination requestAs handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + clientCredentialsTokenCache.clear(); + // Re-apply mock after clearAllMocks + (getIasToken as jest.Mock).mockResolvedValue({ + access_token: 'ias-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid', + jti: 'mock-jti' + }); + }); + + it('uses provider tenant when requestAs is provider-tenant', async () => { + const identityServiceWithAppTid = { + ...services.identity[0], + app_tid: 'provider-tenant-id', + credentials: { + ...services.identity[0].credentials, + app_tid: 'provider-tenant-id' + } + }; + + process.env.VCAP_SERVICES = JSON.stringify({ + ...services, + identity: [identityServiceWithAppTid] + }); + + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { requestAs: 'provider-tenant' } + } + ); + + expect(getIasToken).toHaveBeenCalledWith( + expect.objectContaining({ + label: 'identity' + }), + expect.objectContaining({}) + ); + + // Verify cache was populated with provider tenant + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance: 'tenant.accounts.ondemand.com', + appTid: 'provider-tenant-id', + clientId: 'identity-clientid' + }); + expect(cached?.access_token).toBe('ias-access-token'); + + // Restore original VCAP_SERVICES + process.env.VCAP_SERVICES = JSON.stringify(services); + }); + + it('uses current tenant when requestAs is current-tenant', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'current-tenant-id' }, + iasOptions: { requestAs: 'current-tenant' } + } + ); + + // Verify cache was populated with current tenant + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance: 'tenant.accounts.ondemand.com', + appTid: 'current-tenant-id', + clientId: 'identity-clientid' + }); + expect(cached?.access_token).toBe('ias-access-token'); + }); + + it('defaults to current tenant when requestAs is not specified', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'current-tenant-id' } + } + ); + + // Verify cache was populated with current tenant (default behavior) + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance: 'tenant.accounts.ondemand.com', + appTid: 'current-tenant-id', + clientId: 'identity-clientid' + }); + expect(cached?.access_token).toBe('ias-access-token'); + }); + + it('prioritizes explicit appTid over requestAs', async () => { + const identityServiceWithAppTid = { + ...services.identity[0], + app_tid: 'provider-tenant-id', + credentials: { + ...services.identity[0].credentials, + app_tid: 'provider-tenant-id' + } + }; + + process.env.VCAP_SERVICES = JSON.stringify({ + ...services, + identity: [identityServiceWithAppTid] + }); + + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + iasOptions: { + requestAs: 'provider-tenant', + appTid: 'explicit-tenant-789' + } + } + ); + + // Verify cache was populated with explicit appTid, not provider tenant + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance: 'tenant.accounts.ondemand.com', + appTid: 'explicit-tenant-789', + clientId: 'identity-clientid' + }); + expect(cached?.access_token).toBe('ias-access-token'); + + // Restore original VCAP_SERVICES + process.env.VCAP_SERVICES = JSON.stringify(services); + }); + }); + + describe('transformIasBindingToDestination cache functionality', () => { + beforeEach(() => { + jest.clearAllMocks(); + clientCredentialsTokenCache.clear(); + // Re-apply mock after clearAllMocks + (getIasToken as jest.Mock).mockResolvedValue({ + access_token: 'ias-access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid', + jti: 'mock-jti' + }); + }); + + it('caches IAS token after first request', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' } } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call should use cached token + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' } } + ); + + // Should still only be called once due to cache hit + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('does not cache IAS token if useCache is false', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' }, useCache: false } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call should use cached token + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' }, useCache: false } + ); + + // Should be called twice - no caching + expect(getIasToken).toHaveBeenCalledTimes(2); + }); + + it('does cache IAS token if useCache is true', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' }, useCache: true } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call should use cached token + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' }, useCache: true } + ); + + // Should still only be called once due to cache hit + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('uses cache key with resource parameter', async () => { + const resource = { name: 'my-app' }; + + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call with same resource should use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('does not use cache for different resource parameters', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource: { name: 'app1' } } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call with different resource should NOT use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource: { name: 'app2' } } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(2); + }); + + it('isolates cache by tenant (appTid)', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-123' } } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Different tenant should not use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { jwt: { app_tid: 'tenant-456' } } + ); + + expect(getIasToken).toHaveBeenCalledTimes(2); + }); + + it('supports resource with providerClientId', async () => { + const resource = { providerClientId: 'resource-client-123' }; + + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call with same resource should use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('supports resource with providerClientId and providerTenantId', async () => { + const resource = { + providerClientId: 'resource-client-123', + providerTenantId: 'resource-tenant-456' + }; + + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call with same resource should use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { resource } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('does not cache JWT bearer tokens (OAuth2JWTBearer)', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token' + } + } + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call should NOT use cache for JWT bearer tokens + await transformServiceBindingToDestination( + resolveServiceBinding('identity'), + { + jwt: { app_tid: 'tenant-123' }, + iasOptions: { + authenticationType: 'OAuth2JWTBearer', + assertion: 'user-jwt-token' + } + } + ); + + // Should be called twice - no caching for JWT bearer + expect(getIasToken).toHaveBeenCalledTimes(2); + }); + + it('handles missing app_tid (no JWT, no provider tenant)', async () => { + await transformServiceBindingToDestination( + resolveServiceBinding('identity') + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Verify cache was populated with just IAS tenant (no app_tid) + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance: 'tenant.accounts.ondemand.com', + clientId: 'identity-clientid' + }); + expect(cached?.access_token).toBe('ias-access-token'); + + // Second call without app_tid should use cache + await transformServiceBindingToDestination( + resolveServiceBinding('identity') + ); + + expect(getIasToken).toHaveBeenCalledTimes(1); + }); + + it('isolates cache by IAS tenant (different service URLs)', async () => { + const iasService1 = { + ...services.identity[0], + name: 'ias-service-1', + credentials: { + ...services.identity[0].credentials, + url: 'https://tenant1.accounts.ondemand.com', + clientid: 'client-1' + } + }; + + const iasService2 = { + ...services.identity[0], + name: 'ias-service-2', + credentials: { + ...services.identity[0].credentials, + url: 'https://tenant2.accounts.ondemand.com', + clientid: 'client-2' + } + }; + + // First call to IAS service 1 + await transformServiceBindingToDestination(iasService1, { + jwt: { app_tid: 'tenant-123' } + }); + + expect(getIasToken).toHaveBeenCalledTimes(1); + + // Second call to IAS service 2 with same app_tid should NOT use cache + // because IAS tenant is different + await transformServiceBindingToDestination(iasService2, { + jwt: { app_tid: 'tenant-123' } + }); + + expect(getIasToken).toHaveBeenCalledTimes(2); + + // Third call to IAS service 1 again with same app_tid should use cache + await transformServiceBindingToDestination(iasService1, { + jwt: { app_tid: 'tenant-123' } + }); + + expect(getIasToken).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.ts b/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.ts index 38ae7109b8..5e53438bfb 100644 --- a/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.ts +++ b/packages/connectivity/src/scp-cf/destination/service-binding-to-destination.ts @@ -1,11 +1,19 @@ import { serviceToken } from '../token-accessor'; import { decodeJwt } from '../jwt'; +import { getIasToken } from '../identity-service'; +import { clientCredentialsTokenCache } from '../client-credentials-token-cache'; +import { parseUrlAndGetHost } from '../subdomain-replacer'; import type { Service } from '../environment-accessor'; import type { ServiceBindingTransformFunction, ServiceBindingTransformOptions } from './destination-from-vcap'; -import type { Destination } from './destination-service-types'; +import type { + AuthenticationType, + Destination +} from './destination-service-types'; +import type { IasOptions, IasOptionsTechnicalUser } from './ias-types'; +import type { CachingOptions } from '../cache'; /** * @internal @@ -21,7 +29,8 @@ export const serviceToDestinationTransformers: Record< workflow: workflowBindingToDestination, 'service-manager': serviceManagerBindingToDestination, xsuaa: xsuaaToDestination, - aicore: aicoreToDestination + aicore: aicoreToDestination, + identity: transformIasBindingToDestination }; /** @@ -36,6 +45,7 @@ export const serviceToDestinationTransformers: Record< * - service-manager (OAuth2ClientCredentials) * - xsuaa (OAuth2ClientCredentials) * - aicore (OAuth2ClientCredentials) + * - identity (OAuth2ClientCredentials with mTLS or client secret) * Throws an error if the provided service binding is not supported. * @param serviceBinding - The service binding to transform. * @param options - Options used for fetching the destination. @@ -69,11 +79,7 @@ export async function transformServiceBindingToClientCredentialsDestination( options?: ServiceBindingTransformOptions & { url?: string } ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( - token, - options?.url ?? service.url, - service.name - ); + return buildDestination(token, options?.url ?? service.url, service.name); } async function aicoreToDestination( @@ -81,7 +87,7 @@ async function aicoreToDestination( options?: ServiceBindingTransformOptions ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( + return buildDestination( token, service.credentials.serviceurls.AI_API_URL, service.name @@ -93,11 +99,7 @@ async function xsuaaToDestination( options?: ServiceBindingTransformOptions ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( - token, - service.credentials.apiurl, - service.name - ); + return buildDestination(token, service.credentials.apiurl, service.name); } async function serviceManagerBindingToDestination( @@ -105,11 +107,7 @@ async function serviceManagerBindingToDestination( options?: ServiceBindingTransformOptions ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( - token, - service.credentials.sm_url, - service.name - ); + return buildDestination(token, service.credentials.sm_url, service.name); } async function destinationBindingToDestination( @@ -117,11 +115,7 @@ async function destinationBindingToDestination( options?: ServiceBindingTransformOptions ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( - token, - service.credentials.uri, - service.name - ); + return buildDestination(token, service.credentials.uri, service.name); } async function saasRegistryBindingToDestination( @@ -129,7 +123,7 @@ async function saasRegistryBindingToDestination( options?: ServiceBindingTransformOptions ): Promise { const token = await serviceToken(service, options); - return buildClientCredentialsDestination( + return buildDestination( token, service.credentials['saas_registry_url'], service.name @@ -145,11 +139,7 @@ async function businessLoggingBindingToDestination( credentials: { ...service.credentials.uaa } }; const token = await serviceToken(transformedService, options); - return buildClientCredentialsDestination( - token, - service.credentials.writeUrl, - service.name - ); + return buildDestination(token, service.credentials.writeUrl, service.name); } async function workflowBindingToDestination( @@ -161,7 +151,7 @@ async function workflowBindingToDestination( credentials: { ...service.credentials.uaa } }; const token = await serviceToken(transformedService, options); - return buildClientCredentialsDestination( + return buildDestination( token, service.credentials.endpoints.workflow_odata_url, service.name @@ -178,11 +168,133 @@ async function xfS4hanaCloudBindingToDestination( password: service.credentials.Password }; } +/** + * Tries to resolve `app_tid` based on supplied IAS options. + * @param iasOptions - IAS technical user options. + * @param service - Service binding for identity service. + * @param options - Service binding transform options. + * @returns The BTP app_tid based on `requestAs` configuration. + */ +function getIasAppTid( + iasOptions: IasOptionsTechnicalUser, + service: Service, + options?: ServiceBindingTransformOptions +): string | undefined { + const { requestAs } = iasOptions; + if (requestAs === 'provider-tenant') { + return service.app_tid; + } + if (requestAs === 'current-tenant' || !requestAs) { + return options?.jwt?.app_tid; + } + + requestAs satisfies never; + throw new Error(`Invalid requestAs value: ${requestAs}`); +} + +/** + * Builds destination based on supplied IAS options. + * Uses `targetUrl` as the destination URL if supplied and adds `mtlsKeyPair` if available. + * @param accessToken - The JWT token to access the service. + * @param service - Service binding for identity service. + * @param iasOptions - IAS options to build the destination. + * @returns A destination object. + */ +function buildIasDestination( + accessToken: string, + service: Service, + iasOptions: IasOptions +): Destination { + const destination = buildDestination( + accessToken, + iasOptions?.targetUrl ?? service.credentials.url, + service.name, + iasOptions.authenticationType || 'OAuth2ClientCredentials' + ); + + // Add mTLS key pair if available + if (service.credentials.certificate && service.credentials.key) { + destination.mtlsKeyPair = { + cert: service.credentials.certificate, + key: service.credentials.key + }; + } + return destination; +} + +async function transformIasBindingToDestination( + service: Service, + options?: ServiceBindingTransformOptions +): Promise { + const iasOptions = { + authenticationType: 'OAuth2ClientCredentials' as const, + useCache: options?.useCache !== false, + ...(options?.iasOptions || {}) + } satisfies IasOptions & CachingOptions; + + const iasInstance = parseUrlAndGetHost(service.credentials.url); + + // Technical user client credentials grant preperation + if (iasOptions.authenticationType === 'OAuth2ClientCredentials') { + if (!iasOptions.appTid) { + iasOptions.appTid = getIasAppTid(iasOptions, service, options); + } + + if (iasOptions.useCache) { + const cached = clientCredentialsTokenCache.getTokenIas({ + iasInstance, + appTid: iasOptions.appTid, + clientId: service.credentials.clientid, + resource: options?.iasOptions?.resource + }); + if (cached) { + return buildIasDestination(cached.access_token, service, iasOptions); + } + } + } + + const response = await getIasToken(service, { + jwt: options?.jwt, + ...iasOptions + }); + + if ( + iasOptions.authenticationType === 'OAuth2ClientCredentials' && + iasOptions.useCache && + response + ) { + clientCredentialsTokenCache.cacheIasToken( + { + iasInstance, + appTid: iasOptions.appTid, + clientId: service.credentials.clientid, + resource: iasOptions?.resource + }, + response + ); + } + + return buildIasDestination(response.access_token, service, iasOptions); +} -function buildClientCredentialsDestination( +/** + * Builds a destination object with a token, name, and url. + * If no authentication type is provided, 'OAuth2ClientCredentials' is used by default. + * @internal + * @param token - The access token for the destination. + * @param url - The URL of the destination. + * @param name - The name of the destination. + * @param authentication - The authentication type for the destination. Defaults to 'OAuth2ClientCredentials'. + * @returns A destination object. + */ +function buildDestination( token: string, url: string, - name + name: string, + authentication: Extract< + AuthenticationType, + 'OAuth2ClientCredentials' | 'OAuth2JWTBearer' + > = 'OAuth2ClientCredentials' ): Destination { const expirationTime = decodeJwt(token).exp; const expiresIn = expirationTime @@ -191,7 +303,7 @@ function buildClientCredentialsDestination( return { url, name, - authentication: 'OAuth2ClientCredentials', + authentication, authTokens: [ { value: token, diff --git a/packages/connectivity/src/scp-cf/environment-accessor/environment-accessor-types.ts b/packages/connectivity/src/scp-cf/environment-accessor/environment-accessor-types.ts index d51f734eb9..acc21a1aee 100644 --- a/packages/connectivity/src/scp-cf/environment-accessor/environment-accessor-types.ts +++ b/packages/connectivity/src/scp-cf/environment-accessor/environment-accessor-types.ts @@ -67,3 +67,15 @@ export type XsuaaServiceCredentials = ServiceCredentials & { verificationkey: string; xsappname: string; }; + +/** + * Credentials for the Identity Authentication Service (IAS). + * Matches the type definition from @sap/xssec. + * @internal + */ +export type IdentityServiceCredentials = ServiceCredentials & { + /** + * Application tenant ID. Can be used to override the tenant context. + */ + app_tid?: string; +}; diff --git a/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts b/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts new file mode 100644 index 0000000000..dbaff6ec40 --- /dev/null +++ b/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts @@ -0,0 +1,57 @@ +import { + identityServicesCache, + getIdentityServiceInstanceFromCredentials +} from './ias'; +import type { ServiceCredentials } from './environment-accessor-types'; + +describe('ias', () => { + describe('getIdentityServiceInstanceFromCredentials()', () => { + afterEach(() => { + identityServicesCache.clear(); + }); + + it('creates a new service instance', () => { + expect( + getIdentityServiceInstanceFromCredentials(createServiceCredentials()) + ).toBeDefined(); + }); + + it('retrieves the same service instance for the same credentials', () => { + expect( + getIdentityServiceInstanceFromCredentials(createServiceCredentials()) + ).toBe( + getIdentityServiceInstanceFromCredentials(createServiceCredentials()) + ); + }); + + it('retrieves different service instances for the different credentials', () => { + expect( + getIdentityServiceInstanceFromCredentials(createServiceCredentials()) + ).not.toBe( + getIdentityServiceInstanceFromCredentials( + createServiceCredentials('another-clientid') + ) + ); + }); + + it('retrieves different service instances for the same credentials, but different caching behavior', () => { + expect( + getIdentityServiceInstanceFromCredentials(createServiceCredentials()) + ).not.toBe( + getIdentityServiceInstanceFromCredentials( + createServiceCredentials(), + undefined, + true + ) + ); + }); + }); +}); + +function createServiceCredentials(clientid = 'clientid'): ServiceCredentials { + return { + clientid, + clientsecret: 'clientsecret', + url: 'https://tenant.accounts.ondemand.com' + } as unknown as ServiceCredentials; +} diff --git a/packages/connectivity/src/scp-cf/environment-accessor/ias.ts b/packages/connectivity/src/scp-cf/environment-accessor/ias.ts new file mode 100644 index 0000000000..f16c8b13b5 --- /dev/null +++ b/packages/connectivity/src/scp-cf/environment-accessor/ias.ts @@ -0,0 +1,73 @@ +import { IdentityService, IdentityServiceToken } from '@sap/xssec'; +import { createLogger } from '@sap-cloud-sdk/util'; +import { getIssuerSubdomain, replaceSubdomain } from '../subdomain-replacer'; +import type { IdentityServiceCredentials } from './environment-accessor-types'; +import type { JwtPayload } from '../jsonwebtoken-type'; + +const logger = createLogger({ + package: 'connectivity', + messageContext: 'ias' +}); + +/** + * @internal + * A cache for `IdentityService` instances. + * Direct access from outside this module outside tests is discouraged. + */ +export const identityServicesCache: Map = new Map(); + +/** + * @internal + * @param credentials - Identity service credentials extracted from a service binding or re-use service. Required to create the xssec `IdentityService` instance. + * @param assertion - Optional JWT assertion to extract the issuer URL for bearer assertion flows. + * @param disableCache - Value to enable or disable JWKS cache in the xssec library. Defaults to false. + * @returns An instance of {@link @sap/xssec/IdentityService} for the provided credentials. + */ +export function getIdentityServiceInstanceFromCredentials( + credentials: IdentityServiceCredentials, + assertion?: string, + disableCache: boolean = false +): IdentityService { + const serviceConfig = disableCache + ? { + validation: { + jwks: { + expirationTime: 0, + refreshPeriod: 0 + } + } + } + : undefined; + + let subdomain: string | undefined; + if (assertion) { + // Use `IdentityServiceToken` to take advantage of xssec JWT-decoding cache + const decodedJwt = new IdentityServiceToken(assertion); + const payload = decodedJwt.payload satisfies JwtPayload; + // For IAS tokens, prefer ias_iss claim over standard iss claim + subdomain = getIssuerSubdomain(payload, true); + if (subdomain) { + // Replace subdomain in the URL from the service binding + // Reason: We don't want to blindly trust the URL in the assertion + credentials = { + ...credentials, + url: replaceSubdomain(credentials.url, subdomain) + }; + } else { + logger.warn( + 'Could not extract subdomain from JWT assertion issuer. Falling back to service binding URL.' + ); + } + } + + subdomain = subdomain ?? getIssuerSubdomain({ iss: credentials.url }); + + const cacheKey = `${credentials.clientid}:${subdomain}:${disableCache}`; + // TODO: Use Map.prototype.getOrInsertComputed() when available + let identityService = identityServicesCache.get(cacheKey); + if (identityService === undefined) { + identityService = new IdentityService(credentials, serviceConfig); + identityServicesCache.set(cacheKey, identityService); + } + return identityService; +} diff --git a/packages/connectivity/src/scp-cf/environment-accessor/index.ts b/packages/connectivity/src/scp-cf/environment-accessor/index.ts index 1000e5d362..2bb278309a 100644 --- a/packages/connectivity/src/scp-cf/environment-accessor/index.ts +++ b/packages/connectivity/src/scp-cf/environment-accessor/index.ts @@ -3,3 +3,4 @@ export * from './service-bindings'; export * from './environment-accessor-types'; export * from './service-credentials'; export * from './xsuaa'; +export * from './ias'; diff --git a/packages/connectivity/src/scp-cf/environment-accessor/xsuaa.ts b/packages/connectivity/src/scp-cf/environment-accessor/xsuaa.ts index 1e461510f3..87bb961948 100644 --- a/packages/connectivity/src/scp-cf/environment-accessor/xsuaa.ts +++ b/packages/connectivity/src/scp-cf/environment-accessor/xsuaa.ts @@ -19,7 +19,7 @@ export function clearXsuaaServices(): void { * @internal * @param credentials - Xsuaa credentials extracted from a re-use service like destination service. Required to create the xssec XSUAA instance. * @param disableCache - Value to enable or disable JWKS cache in xssec library. Defaults to false. - * @returns An instance of {@code @sap/xssec/XsuaaService} for the provided credentials. + * @returns An instance of {@link @sap/xssec/XsuaaService} for the provided credentials. */ export function getXsuaaInstanceFromServiceCredentials( credentials: ServiceCredentials, diff --git a/packages/connectivity/src/scp-cf/identity-service.spec.ts b/packages/connectivity/src/scp-cf/identity-service.spec.ts index 2332035546..aabdd38471 100644 --- a/packages/connectivity/src/scp-cf/identity-service.spec.ts +++ b/packages/connectivity/src/scp-cf/identity-service.spec.ts @@ -1,5 +1,40 @@ import { signedJwt } from '../../../../test-resources/test/test-util'; -import { shouldExchangeToken } from './identity-service'; +import { + getIasToken, + shouldExchangeToken, + identityServicesCache +} from './identity-service'; +import type { Service } from './environment-accessor'; + +const mockFetchClientCredentialsToken = jest.fn(); +const mockFetchJwtBearerToken = jest.fn(); + +jest.mock('@sap/xssec', () => { + const mockGetSafeUrlFromTokenIssuer = jest.fn(); + const mockIdentityService: any = jest.fn().mockImplementation(() => ({ + fetchClientCredentialsToken: mockFetchClientCredentialsToken, + fetchJwtBearerToken: mockFetchJwtBearerToken + })); + mockIdentityService.getSafeUrlFromTokenIssuer = mockGetSafeUrlFromTokenIssuer; + + return { + ...jest.requireActual('@sap/xssec'), + IdentityService: mockIdentityService, + IdentityServiceToken: jest.fn().mockImplementation((jwt: string) => { + const payload = JSON.parse( + Buffer.from(jwt.split('.')[1], 'base64').toString() + ); + return { + payload, + appTid: payload.app_tid ?? payload.zone_uuid, + scimId: payload.scim_id, + consumedApis: payload.ias_apis, + customIssuer: payload.iss, + issuer: payload.iss + }; + }) + }; +}); describe('shouldExchangeToken', () => { it('should not exchange token from XSUAA', async () => { @@ -10,9 +45,12 @@ describe('shouldExchangeToken', () => { ).toBe(false); }); - it('should exchange non-XSUAA token', async () => { + it('should exchange IAS token', async () => { expect( - shouldExchangeToken({ iasToXsuaaTokenExchange: true, jwt: signedJwt({}) }) + shouldExchangeToken({ + iasToXsuaaTokenExchange: true, + jwt: signedJwt({ iss: 'https://tenant.accounts.ondemand.com' }) + }) ).toBe(true); }); @@ -37,3 +75,531 @@ describe('shouldExchangeToken', () => { ).toBe(false); }); }); + +describe('getIasToken', () => { + const mockIasService: Service = { + name: 'my-identity-service', + label: 'identity', + tags: ['identity'], + credentials: { + url: 'https://tenant.accounts.ondemand.com', + clientid: 'test-client-id', + certificate: + '-----BEGIN CERTIFICATE-----\ntest-cert\n-----END CERTIFICATE-----', + key: '-----BEGIN RSA PRIVATE KEY-----\ntest-key\n-----END RSA PRIVATE KEY-----' + } + }; + + const mockTokenResponse = { + access_token: signedJwt({ + jti: 'mock-jti', + aud: 'test-audience', + ias_apis: ['dummy'], + // Fallback value if app_tid missing (legacy) + zone_uuid: 'custom-tenant-id', + iss: 'https://tenant.accounts.ondemand.com', + ias_iss: 'https://ias-tenant.accounts.ondemand.com' + }), + token_type: 'Bearer', + expires_in: 3600 + }; + + beforeEach(() => { + jest.clearAllMocks(); + identityServicesCache.clear(); + }); + + it('fetches IAS token with mTLS authentication', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + const result = await getIasToken(mockIasService); + + expect(result).toEqual({ + access_token: mockTokenResponse.access_token, + token_type: mockTokenResponse.token_type, + expires_in: mockTokenResponse.expires_in, + scope: '', + jti: 'mock-jti', + aud: 'test-audience', + app_tid: 'custom-tenant-id', + custom_iss: 'https://tenant.accounts.ondemand.com', + ias_apis: ['dummy'], + scim_id: undefined + }); + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + token_format: 'jwt' + }); + }); + + it('fetches IAS token with client secret authentication', async () => { + const serviceWithSecret: Service = { + ...mockIasService, + credentials: { + url: 'https://tenant.accounts.ondemand.com', + clientid: 'test-client-id', + clientsecret: 'test-client-secret' + } + }; + + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + const result = await getIasToken(serviceWithSecret); + + expect(result).toEqual({ + access_token: mockTokenResponse.access_token, + token_type: mockTokenResponse.token_type, + expires_in: mockTokenResponse.expires_in, + scope: '', + jti: 'mock-jti', + aud: 'test-audience', + app_tid: 'custom-tenant-id', + custom_iss: 'https://tenant.accounts.ondemand.com', + ias_apis: ['dummy'], + scim_id: undefined + }); + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + token_format: 'jwt' + }); + }); + + it('includes resource parameter for app2app flow', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, { + resource: { name: 'my-app' } + }); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + resource: 'urn:sap:identity:application:provider:name:my-app', + token_format: 'jwt' + }); + }); + + it('includes appTid parameter for multi-tenant scenarios', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, { + appTid: 'tenant-123' + }); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + app_tid: 'tenant-123', + token_format: 'jwt' + }); + }); + + it('includes both appName and appTid parameters', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, { + resource: { name: 'my-app' }, + appTid: 'tenant-123' + }); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + resource: 'urn:sap:identity:application:provider:name:my-app', + app_tid: 'tenant-123', + token_format: 'jwt' + }); + }); + + it('includes extraParams for additional OAuth2 parameters', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, { + extraParams: { custom_param: 'custom_value' } as any + }); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalledWith({ + token_format: 'jwt', + custom_param: 'custom_value' + }); + }); + + it('handles token fetch errors gracefully', async () => { + mockFetchClientCredentialsToken.mockRejectedValue( + new Error('Network error') + ); + + await expect(getIasToken(mockIasService)).rejects.toThrow( + 'Could not fetch IAS client for service "my-identity-service" of type identity: Network error' + ); + }); + + it('adds multi-tenant hint for 401 errors', async () => { + const error: any = new Error('Unauthorized'); + error.response = { status: 401 }; + mockFetchClientCredentialsToken.mockRejectedValue(error); + + await expect(getIasToken(mockIasService)).rejects.toThrow( + /ensure that the service instance is declared as dependency to SaaS Provisioning Service or Subscription Manager/ + ); + }); + + describe('authenticationType parameter', () => { + it('uses OAuth2ClientCredentials (technical-user) by default', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, {}); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalled(); + expect(mockFetchJwtBearerToken).not.toHaveBeenCalled(); + }); + + it('uses client credentials for OAuth2ClientCredentials', async () => { + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(mockIasService, { + authenticationType: 'OAuth2ClientCredentials' + }); + + expect(mockFetchClientCredentialsToken).toHaveBeenCalled(); + expect(mockFetchJwtBearerToken).not.toHaveBeenCalled(); + }); + + it('uses JWT bearer grant for OAuth2JWTBearer (business-user) with assertion', async () => { + mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse); + + const userAssertion = signedJwt({ + iss: 'https://tenant.accounts.ondemand.com', + user_uuid: 'user-123', + app_tid: 'tenant-456' + }); + + await getIasToken(mockIasService, { + authenticationType: 'OAuth2JWTBearer', + assertion: userAssertion + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(userAssertion, { + token_format: 'jwt', + app_tid: 'tenant-456', + refresh_expiry: 0 + }); + expect(mockFetchClientCredentialsToken).not.toHaveBeenCalled(); + }); + + it('throws error for OAuth2JWTBearer (business-user) without assertion', async () => { + await expect( + getIasToken(mockIasService, { + authenticationType: 'OAuth2JWTBearer' + } as any) + ).rejects.toThrow( + 'JWT assertion required for authenticationType: "OAuth2JWTBearer"' + ); + }); + + it('supports OAuth2JWTBearer (business-user) with resource and appTid', async () => { + mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse); + + const userAssertion = signedJwt({ + iss: 'https://tenant.accounts.ondemand.com', + user_uuid: 'user-123', + app_tid: 'tenant-456' + }); + + await getIasToken(mockIasService, { + authenticationType: 'OAuth2JWTBearer', + assertion: userAssertion, + resource: { name: 'my-app' }, + appTid: 'tenant-123' + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(userAssertion, { + resource: 'urn:sap:identity:application:provider:name:my-app', + app_tid: 'tenant-123', + refresh_expiry: 0, + token_format: 'jwt' + }); + }); + }); + + describe('multi-tenant subscriber routing', () => { + const providerUrl = 'https://provider.accounts.ondemand.com'; + const subscriberUrl = 'https://subscriber.accounts.ondemand.com'; + + const providerService: Service = { + name: 'provider-ias', + label: 'identity', + tags: ['identity'], + credentials: { + url: providerUrl, + clientid: 'test-client-id', + clientsecret: 'test-secret' + } + }; + + beforeEach(() => { + jest.clearAllMocks(); + identityServicesCache.clear(); + mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse); + }); + + it('uses provider IdentityService when JWT issuer matches provider URL', async () => { + const assertion = signedJwt({ + iss: providerUrl, + user_uuid: 'user-123' + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + token_format: 'jwt', + app_tid: null + }); + }); + + it('creates subscriber IdentityService when JWT issuer differs from provider', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123' + }); + + const { IdentityService } = jest.requireMock('@sap/xssec'); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + // Verify subscriber instance created with subscriber URL + expect(IdentityService).toHaveBeenCalledWith( + expect.objectContaining({ + url: subscriberUrl + }), + undefined + ); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + token_format: 'jwt', + app_tid: null + }); + }); + + it('does not route for client credentials flow', async () => { + const { IdentityService } = jest.requireMock('@sap/xssec'); + + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + await getIasToken(providerService, { + authenticationType: 'OAuth2ClientCredentials' + }); + + // Should only create provider instance + expect(IdentityService).toHaveBeenCalledTimes(1); + expect(IdentityService).toHaveBeenCalledWith( + expect.objectContaining({ + url: providerUrl + }), + undefined + ); + }); + + it('handles JWT without issuer gracefully with fallback', async () => { + const assertion = signedJwt({ + // Missing issuer field - should fall back to provider URL + user_uuid: 'user-123' + }); + + const result = await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + // Should succeed with fallback to provider credentials + expect(result.access_token).toBeDefined(); + expect(mockFetchJwtBearerToken).toHaveBeenCalled(); + }); + + it('disables refresh tokens for JWT bearer exchanges with APP-to-APP flow', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123' + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion, + resource: { name: 'my-app' } + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + resource: 'urn:sap:identity:application:provider:name:my-app', + refresh_expiry: 0, + token_format: 'jwt', + app_tid: null + }); + }); + + it('includes refresh_token when returned by fetchJwtBearerToken', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123' + }); + + const responseWithRefreshToken = { + ...mockTokenResponse, + refresh_token: 'test-refresh-token' + }; + mockFetchJwtBearerToken.mockResolvedValue(responseWithRefreshToken); + + const result = await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + expect(result.refresh_token).toBe('test-refresh-token'); + }); + + it('disables refresh tokens for JWT bearer exchanges if app_tid is set', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123' + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion, + appTid: 'some-tenant-id' + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + app_tid: 'some-tenant-id', + refresh_expiry: 0, + token_format: 'jwt' + }); + }); + + it('extracts app_tid from assertion when not explicitly provided', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123', + app_tid: 'extracted-tenant-id' + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + app_tid: 'extracted-tenant-id', + refresh_expiry: 0, + token_format: 'jwt' + }); + }); + + it('sets app_tid to null when neither app_tid nor zone_uuid exist in assertion', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123' + // No app_tid or zone_uuid + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + app_tid: null, + token_format: 'jwt' + }); + }); + + it('does not override explicit app_tid with value from assertion', async () => { + const assertion = signedJwt({ + iss: subscriberUrl, + user_uuid: 'user-123', + app_tid: 'assertion-tenant-id' + }); + + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion, + appTid: 'explicit-tenant-id' + }); + + expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, { + app_tid: 'explicit-tenant-id', + refresh_expiry: 0, + token_format: 'jwt' + }); + }); + + it('caches subscriber instances per URL', async () => { + const subscriber1Url = 'https://subscriber1.accounts.ondemand.com'; + const subscriber2Url = 'https://subscriber2.accounts.ondemand.com'; + + const assertion1 = signedJwt({ + iss: subscriber1Url, + user_uuid: 'user-123' + }); + + const assertion2 = signedJwt({ + iss: subscriber2Url, + user_uuid: 'user-456' + }); + + const { IdentityService } = jest.requireMock('@sap/xssec'); + + // First call with subscriber1 + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion: assertion1 + }); + + const callsAfterFirst = IdentityService.mock.calls.length; + + // Second call with same subscriber1 - should use cached instance + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion: assertion1 + }); + + // Should not create new instance (cached) + expect(IdentityService.mock.calls.length).toBe(callsAfterFirst); + + // Third call with different subscriber2 - should create new instance + await getIasToken(providerService, { + authenticationType: 'OAuth2JWTBearer', + assertion: assertion2 + }); + + // Should create new instance for subscriber2 + expect(IdentityService.mock.calls.length).toBeGreaterThan( + callsAfterFirst + ); + }); + + it('handles URLs with trailing slashes correctly', async () => { + const providerWithSlash = 'https://provider.accounts.ondemand.com/'; + + const serviceWithSlash: Service = { + ...providerService, + credentials: { + ...providerService.credentials, + url: providerWithSlash + } + }; + + const assertion = signedJwt({ + iss: 'https://provider.accounts.ondemand.com', + user_uuid: 'user-123' + }); + + const { IdentityService } = jest.requireMock('@sap/xssec'); + + await getIasToken(serviceWithSlash, { + authenticationType: 'OAuth2JWTBearer', + assertion + }); + + // Should handle URLs with and without trailing slashes + expect(IdentityService).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/connectivity/src/scp-cf/identity-service.ts b/packages/connectivity/src/scp-cf/identity-service.ts index 68b5d6a33d..c2907295d6 100644 --- a/packages/connectivity/src/scp-cf/identity-service.ts +++ b/packages/connectivity/src/scp-cf/identity-service.ts @@ -1,5 +1,59 @@ +import { executeWithMiddleware } from '@sap-cloud-sdk/resilience/internal'; +import { resilience } from '@sap-cloud-sdk/resilience'; +import { IdentityServiceToken, type IdentityService } from '@sap/xssec'; +import { ErrorWithCause } from '@sap-cloud-sdk/util'; import { decodeJwt, isXsuaaToken } from './jwt'; -import type { DestinationOptions } from './destination'; +import { + resolveServiceBinding, + getIdentityServiceInstanceFromCredentials +} from './environment-accessor'; +import type { + DestinationOptions, + IasOptions, + IasResource +} from './destination'; +import type { MiddlewareContext } from '@sap-cloud-sdk/resilience'; +import type { Service, ServiceCredentials } from './environment-accessor'; +import type { ClientCredentialsResponse } from './xsuaa-service-types'; +import type { JwtPayload } from './jsonwebtoken-type'; + +export { identityServicesCache } from './environment-accessor'; + +/** + * @internal + * Represents the response to an IAS token request using client credentials or JWT bearer grant. + * This interface extends the XSUAA `ClientCredentialsResponse` response with IAS-specific fields. + */ +export interface IasTokenResponse extends ClientCredentialsResponse { + /** + * Audience claim from the JWT token. + */ + aud: string | string[]; + /** + * IAS API resources. Empty when no resource parameter is specified in the token request. + */ + ias_apis: string[]; + /** + * The SCIM ID of the user (not present for technical user tokens). + */ + scim_id?: string; + /** + * Custom issuer claim from the JWT token. + */ + custom_iss?: string; + /** + * Application tenant ID claim from the JWT token. + */ + app_tid?: string; + /** + * IAS tokens don't have scope property. + */ + scope: ''; + /** + * @internal + */ + refresh_token?: string; +} /** * @internal @@ -15,3 +69,174 @@ export function shouldExchangeToken(options: DestinationOptions): boolean { !isXsuaaToken(decodeJwt(options.jwt)) ); } + +type IasParameters = { + jwt?: JwtPayload; + serviceCredentials: ServiceCredentials; +} & IasOptions; + +/** + * Make a client credentials request against the IAS OAuth2 endpoint. + * Supports both certificate-based (mTLS) and client secret authentication. + * @param service - Service as it is defined in the environment variable. + * @param options - Options for token fetching, including authenticationType to specify authentication mode, optional resource parameter for app2app, appTid for multi-tenant scenarios, and extraParams for additional OAuth2 parameters. + * @returns Client credentials token response. + * @internal + */ +export async function getIasToken( + service: string | Service, + options: IasOptions & { jwt?: JwtPayload } = {} +): Promise { + const resolvedService = resolveServiceBinding(service); + + const fnArgument: IasParameters = { + serviceCredentials: resolvedService.credentials, + ...options + }; + + const token = await executeWithMiddleware< + IasParameters, + IasTokenResponse, + MiddlewareContext + >(resilience(), { + fn: getIasTokenImpl, + fnArgument, + context: { + uri: fnArgument.serviceCredentials.url, + tenantId: fnArgument.serviceCredentials.app_tid + } + }).catch(err => { + const serviceName = + typeof service === 'string' ? service : service.name || 'unknown'; + let message = `Could not fetch IAS client for service "${serviceName}" of type ${resolvedService.label}`; + + // Add contextual hints based on error status code (similar to Java SDK) + if (err.response?.status === 401) { + message += + '. In case you are accessing a multi-tenant BTP service on behalf of a subscriber tenant, ensure that the service instance is declared as dependency to SaaS Provisioning Service or Subscription Manager (SMS) and subscribed for the current tenant'; + } + + throw new ErrorWithCause( + message + (err.message ? `: ${err.message}` : '.'), + err + ); + }); + return token; +} + +/** + * Converts an IAS resource to the URN format expected by @sap/xssec. + * @param resource - The IAS resource to convert. + * @returns The resource in URN format. + * @internal + */ +function convertResourceToUrn(resource: IasResource): string { + if (!resource) { + throw new Error('Resource parameter is required'); + } + + if ('name' in resource) { + return `urn:sap:identity:application:provider:name:${resource.name}`; + } + + const segments = [ + `urn:sap:identity:application:provider:clientid:${resource.providerClientId}` + ]; + if (resource.providerTenantId) { + segments.push(`apptid:${resource.providerTenantId}`); + } + return segments.join(':'); +} + +/** + * Transforms IAS options to the format expected by @sap/xssec. + * @param arg - The IAS parameters including options. + * @returns The transformed token fetch options. + * @internal + */ +function transformIasOptionsToXssecArgs( + arg: IasParameters +): IdentityService.TokenFetchOptions & + IdentityService.IdentityServiceTokenFetchOptions { + const tokenOptions = { + token_format: 'jwt', + ...(arg.resource && { resource: convertResourceToUrn(arg.resource) }), + ...(arg.appTid && { app_tid: arg.appTid }), + ...(arg.extraParams || {}) + } satisfies IdentityService.TokenFetchOptions & + IdentityService.IdentityServiceTokenFetchOptions; + + if (arg.authenticationType === 'OAuth2JWTBearer') { + // JWT bearer grant for business user propagation + if (!arg.assertion) { + throw new Error( + 'JWT assertion required for authenticationType: "OAuth2JWTBearer". Provide iasOptions.assertion.' + ); + } + + // Disable refresh token for App-To-App JWT bearer token exchange (recommended for better performance) + if (arg.resource && tokenOptions.refresh_expiry === undefined) { + tokenOptions.refresh_expiry = 0; + } + + // Extract appTid from assertion if not provided + const token = new IdentityServiceToken(arg.assertion); + if (!tokenOptions.app_tid) { + // Set to `null` if not set to prevent xssec from also trying to extract it internally + tokenOptions.app_tid = token?.appTid || (null as unknown as undefined); + } + + // Workaround for IAS bug + // JAVA SDK: https://github.com/SAP/cloud-sdk-java/blob/61903347b607a8397f7930709cd52526f05269b1/cloudplatform/connectivity-oauth/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/OAuth2Service.java#L225-L236 + // Issue: https://jira.tools.sap/browse/SECREQ-5220 + if (tokenOptions.app_tid) { + tokenOptions.refresh_expiry = 0; + } + } + + return tokenOptions; +} + +/** + * Implementation of the IAS client credentials token retrieval using @sap/xssec. + * @param arg - The parameters for IAS token retrieval. + * @returns A promise resolving to the client credentials response. + * @internal + */ +async function getIasTokenImpl(arg: IasParameters): Promise { + const identityService = getIdentityServiceInstanceFromCredentials( + arg.serviceCredentials, + arg.assertion + ); + + const tokenOptions = transformIasOptionsToXssecArgs(arg); + + const response: IdentityService.TokenFetchResponse = + arg.authenticationType === 'OAuth2JWTBearer' + ? // JWT bearer grant for business user access + await identityService.fetchJwtBearerToken(arg.assertion, tokenOptions) + : // Technical user client credentials grant + await identityService.fetchClientCredentialsToken(tokenOptions); + + const decodedJwt = new IdentityServiceToken(response.access_token); + + return { + access_token: response.access_token, + token_type: response.token_type, + expires_in: response.expires_in, + // IAS tokens don't have scope property + scope: '', + jti: decodedJwt.payload?.jti ?? '', + // `decodedJwt.audiences` always returns an array, preserve original type + aud: decodedJwt.payload?.aud ?? [], + app_tid: decodedJwt.appTid, + scim_id: decodedJwt.scimId, + // Added if resource parameter was specified + ias_apis: decodedJwt?.consumedApis, + custom_iss: decodedJwt.customIssuer ?? undefined, + // fetchJwtBearerToken may return a refresh token + refresh_token: ( + response as unknown as IdentityService.RefreshableTokenFetchResponse + )?.refresh_token + }; +} diff --git a/packages/connectivity/src/scp-cf/jwt/jwt.spec.ts b/packages/connectivity/src/scp-cf/jwt/jwt.spec.ts index 258f1f92f6..8e9d949b6a 100644 --- a/packages/connectivity/src/scp-cf/jwt/jwt.spec.ts +++ b/packages/connectivity/src/scp-cf/jwt/jwt.spec.ts @@ -4,9 +4,29 @@ import { mockServiceBindings, signedJwtForVerification } from '../../../../../test-resources/test/test-util'; -import { audiences, retrieveJwt, isXsuaaToken, decodeJwt } from './jwt'; +import { audiences, decodeJwt, isXsuaaToken, retrieveJwt, userId } from './jwt'; describe('jwt', () => { + describe('userId', () => { + it('extracts user_id from XSUAA tokens', () => { + const xsuaaPayload = { user_id: 'xsuaa-user-123' }; + expect(userId(xsuaaPayload)).toBe('xsuaa-user-123'); + }); + + it('extracts user_uuid from IAS tokens', () => { + const iasPayload = { user_uuid: 'ias-user-uuid-456' }; + expect(userId(iasPayload)).toBe('ias-user-uuid-456'); + }); + + it('prefers user_id over user_uuid when both are present', () => { + const mixedPayload = { + user_uuid: 'ias-user-uuid-456', + user_id: 'xsuaa-user-123' + }; + expect(userId(mixedPayload)).toBe('xsuaa-user-123'); + }); + }); + describe('isXsuaaToken()', () => { it('returns true if the token was issued by XSUAA', () => { const jwt = decodeJwt( diff --git a/packages/connectivity/src/scp-cf/jwt/jwt.ts b/packages/connectivity/src/scp-cf/jwt/jwt.ts index 92271fe043..069f415db9 100644 --- a/packages/connectivity/src/scp-cf/jwt/jwt.ts +++ b/packages/connectivity/src/scp-cf/jwt/jwt.ts @@ -27,12 +27,18 @@ function makeArray(val: string | string[] | undefined): string[] { /** * @internal * Get the user ID from the JWT payload. + * For XSUAA tokens, this is `user_id`. + * For IAS tokens, this is `user_uuid`. * @param jwtPayload - Token payload to read the user ID from. * @returns The user ID, if available. */ -export function userId({ user_id }: JwtPayload): string { - logger.debug(`JWT user_id is: ${user_id}.`); - return user_id; +export function userId(jwtPayload: JwtPayload): string { + // IAS tokens use user_uuid, XSUAA tokens use user_id + const id = jwtPayload.user_id || jwtPayload.user_uuid; + logger.debug( + `JWT user identifier is: ${id} (from ${jwtPayload.user_id ? 'user_id (XSUAA)' : 'user_uuid (IAS)'}).` + ); + return id; } /** @@ -48,7 +54,7 @@ export function getDefaultTenantId(): string { } /** - * Get the tenant ID of a decoded JWT, based on its `zid` or if not available `app_tid` property. + * Get the tenant ID of a decoded JWT, based on its `zid` or if not available `app_tid` or `zone_uuid` (legacy) property. * @param jwt - Token to read the tenant ID from. * @returns The tenant ID, if available. */ @@ -57,23 +63,35 @@ export function getTenantId( ): string | undefined { const decodedJwt = jwt ? decodeJwt(jwt) : {}; logger.debug( - `JWT zid is: ${decodedJwt.zid}, app_tid is: ${decodedJwt.app_tid}.` + `JWT zid is: ${decodedJwt.zid}, app_tid is: ${decodedJwt.app_tid}, zone_uuid is: ${decodedJwt.zone_uuid}.` + ); + return ( + decodedJwt.zid || decodedJwt.app_tid || decodedJwt.zone_uuid || undefined ); - return decodedJwt.zid || decodedJwt.app_tid || undefined; } /** - * Check if the given JWT is not an IAS token. + * Check if the given JWT is an IAS token. * Currently, there are only two domains for IAS tokens: - * `accounts.ondemand.com` and `accounts400.onemand.com`. + * `accounts.ondemand.com` and `accounts400.ondemand.com`. * @param decodedJwt - The decoded JWT to check. - * @returns Whether the given JWT is not an IAS token. + * @returns Whether the given JWT is an IAS token. + * @internal */ -function isNotIasToken(decodedJwt: JwtPayload): boolean { - return ( - !decodedJwt.iss?.includes('accounts.ondemand.com') && - !decodedJwt.iss?.includes('accounts400.ondemand.com') - ); +export function isIasToken(decodedJwt: JwtPayload): boolean { + if (!decodedJwt.iss) { + return false; + } + try { + const issUrl = new URL(decodedJwt.iss); + const hostname = issUrl.hostname.toLowerCase(); + return ( + hostname.endsWith('.accounts.ondemand.com') || + hostname.endsWith('.accounts400.ondemand.com') + ); + } catch { + return false; + } } /** @@ -90,7 +108,7 @@ export function getSubdomain( const decodedJwt = jwt ? decodeJwt(jwt) : {}; return ( decodedJwt?.ext_attr?.zdn || - (isNotIasToken(decodedJwt) ? getIssuerSubdomain(decodedJwt) : undefined) + (isIasToken(decodedJwt) ? undefined : getIssuerSubdomain(decodedJwt)) ); } diff --git a/packages/connectivity/src/scp-cf/subdomain-replacer.ts b/packages/connectivity/src/scp-cf/subdomain-replacer.ts index 9567098a23..029082e548 100644 --- a/packages/connectivity/src/scp-cf/subdomain-replacer.ts +++ b/packages/connectivity/src/scp-cf/subdomain-replacer.ts @@ -1,31 +1,49 @@ import { URL } from 'url'; +import { removeTrailingSlashes } from '@sap-cloud-sdk/util'; import type { JwtPayload } from './jsonwebtoken-type'; /** * @internal */ export function getIssuerSubdomain( - decodedJwt: JwtPayload | undefined + decodedJwt: JwtPayload | undefined, + isIasToken: boolean = false ): string | undefined { - const iss = decodedJwt?.iss; - if (iss) { - if (!isValidUrl(iss)) { - throw new Error(`Issuer URL in JWT is not a valid URL: "${iss}".`); + // For IAS tokens, prefer ias_iss claim over standard iss claim + const issuer = + isIasToken && decodedJwt?.ias_iss ? decodedJwt.ias_iss : decodedJwt?.iss; + + if (issuer) { + if (!isValidUrl(issuer)) { + throw new Error(`Issuer URL in JWT is not a valid URL: "${issuer}".`); } - return getHost(new URL(iss)).split('.')[0]; + return getHost(new URL(issuer)).split('.')[0]; } } function getHost(url: URL): string { const { host } = url; if (!host || host.indexOf('.') === -1) { - throw new Error( - `Failed to determine sub-domain: invalid host in "${url}".` - ); + throw new Error(`Failed to determine hostname: invalid host in "${url}".`); } return host; } +/** + * @internal + * This functions returns the host part of an URL, with URL validation. + * @param url + * @returns host + */ +export function parseUrlAndGetHost(url: string): string { + if (!isValidUrl(url)) { + throw new Error(`URL is not a valid URL: "${url}".`); + } + + const parsed = new URL(url); + return getHost(parsed); +} + function isValidUrl(url: string): boolean { try { new URL(url); @@ -34,3 +52,29 @@ function isValidUrl(url: string): boolean { return false; } } + +/** + * @internal + * Replaces the first part of the hostname (subdomain) in a URL. + * @param baseUrl - The URL whose subdomain should be replaced. + * @param newSubdomain - The new subdomain to use. + * @returns The URL with replaced subdomain, with trailing slash removed if present. + */ +export function replaceSubdomain( + baseUrl: string, + newSubdomain: string +): string { + if (!isValidUrl(baseUrl)) { + throw new Error(`Base URL is not a valid URL: "${baseUrl}".`); + } + + const url = new URL(baseUrl); + const hostParts = getHost(url).split('.'); + url.hostname = [newSubdomain, ...hostParts.slice(1)].join('.'); + + let result = url.toString(); + // Remove trailing slash for consistency + result = removeTrailingSlashes(result); + + return result; +} diff --git a/packages/connectivity/src/scp-cf/token-accessor.ts b/packages/connectivity/src/scp-cf/token-accessor.ts index 7d2b210436..80d9d369c9 100644 --- a/packages/connectivity/src/scp-cf/token-accessor.ts +++ b/packages/connectivity/src/scp-cf/token-accessor.ts @@ -36,6 +36,7 @@ export async function serviceToken( }; const serviceBinding = resolveServiceBinding(service); + const serviceCredentials = serviceBinding.credentials; const tenantForCaching = options?.jwt ? getTenantId(options.jwt) || getSubdomain(options.jwt) diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js index 4766b9b2a6..b5758600fd 100644 --- a/packages/eslint-config/index.js +++ b/packages/eslint-config/index.js @@ -220,7 +220,16 @@ module.exports = { 'jsdoc/check-param-names': 'error', 'jsdoc/check-tag-names': [ 'error', - { definedTags: ['packageDocumentation', 'typeParam', 'experimental'] } + { + definedTags: [ + 'packageDocumentation', + 'typeParam', + 'experimental', + 'defaultValue' + ], + // The other default-allowed tags are not supported by tsdoc + inlineTags: ['link'] + } ], 'jsdoc/check-syntax': 'error', 'jsdoc/multiline-blocks': 'error',