From 420e48a81950b60c91a011210f41902ebfb02043 Mon Sep 17 00:00:00 2001 From: David Knaack Date: Wed, 28 Jan 2026 16:15:25 +0100 Subject: [PATCH] fix: Handle subdomain-replacement for IAS technical user authentication --- .../scp-cf/environment-accessor/ias.spec.ts | 140 +++++++++++++++++- .../src/scp-cf/environment-accessor/ias.ts | 60 +++++--- .../src/scp-cf/identity-service.spec.ts | 74 +++++++++ .../src/scp-cf/identity-service.ts | 11 +- 4 files changed, 262 insertions(+), 23 deletions(-) diff --git a/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts b/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts index dbaff6ec40..d55531707e 100644 --- a/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts +++ b/packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts @@ -1,3 +1,4 @@ +import { signedJwt } from '../../../../../test-resources/test/test-util'; import { identityServicesCache, getIdentityServiceInstanceFromCredentials @@ -45,13 +46,148 @@ describe('ias', () => { ) ); }); + + it('extracts subdomain from JWT string and replaces URL subdomain', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + const jwtString = signedJwt({ + iss: 'https://subscriber.accounts.ondemand.com' + }); + + const service = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString + ); + + // Verify the instance was created (subdomain extraction should succeed) + expect(service).toBeDefined(); + }); + + it('extracts subdomain from JWT payload object and replaces URL subdomain', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + const jwtPayload = { + iss: 'https://subscriber.accounts.ondemand.com' + }; + + const service = getIdentityServiceInstanceFromCredentials( + credentials, + jwtPayload + ); + + // Verify the instance was created (subdomain extraction should succeed) + expect(service).toBeDefined(); + }); + + it('prefers ias_iss claim over iss claim for IAS tokens', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + const jwtString = signedJwt({ + iss: 'https://wrong-subdomain.accounts.ondemand.com', + ias_iss: 'https://correct-subdomain.accounts.ondemand.com' + }); + + const service = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString + ); + + // Verify the instance was created with ias_iss subdomain + expect(service).toBeDefined(); + }); + + it('creates different instances for different subdomains from JWT', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + + const jwtString1 = signedJwt({ + iss: 'https://subscriber1.accounts.ondemand.com' + }); + + const jwtString2 = signedJwt({ + iss: 'https://subscriber2.accounts.ondemand.com' + }); + + const service1 = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString1 + ); + + const service2 = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString2 + ); + + // Different subdomains should result in different service instances + expect(service1).not.toBe(service2); + }); + + it('returns same instance when called with same JWT subdomain', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + + const jwtString1 = signedJwt({ + iss: 'https://subscriber.accounts.ondemand.com' + }); + + const jwtString2 = signedJwt({ + iss: 'https://subscriber.accounts.ondemand.com', + // Different content but same subdomain + user_id: 'different-user' + }); + + const service1 = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString1 + ); + + const service2 = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString2 + ); + + // Same subdomain should result in cached instance + expect(service1).toBe(service2); + }); + + it('falls back to service binding URL when JWT has no issuer', () => { + const credentials = createServiceCredentials( + 'client-id', + 'https://provider.accounts.ondemand.com' + ); + const jwtString = signedJwt({ + // No iss or ias_iss claim + user_id: 'user-123' + }); + + const service = getIdentityServiceInstanceFromCredentials( + credentials, + jwtString + ); + + // Should succeed using fallback to service binding URL + expect(service).toBeDefined(); + }); }); }); -function createServiceCredentials(clientid = 'clientid'): ServiceCredentials { +function createServiceCredentials( + clientid = 'clientid', + url = 'https://tenant.accounts.ondemand.com' +): ServiceCredentials { return { clientid, clientsecret: 'clientsecret', - url: 'https://tenant.accounts.ondemand.com' + url } 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 index f16c8b13b5..c293ec582b 100644 --- a/packages/connectivity/src/scp-cf/environment-accessor/ias.ts +++ b/packages/connectivity/src/scp-cf/environment-accessor/ias.ts @@ -16,16 +16,50 @@ const logger = createLogger({ */ export const identityServicesCache: Map = new Map(); +/** + * Extracts the subdomain from JWT and updates credentials if found. + * @param credentials - Identity service credentials. + * @param jwt - JWT string or payload to extract subdomain from. + * @returns Updated credentials and extracted subdomain (if any). + */ +function extractSubdomainFromJwt( + credentials: IdentityServiceCredentials, + jwt: string | JwtPayload +): { credentials: IdentityServiceCredentials; subdomain: string | undefined } { + const decodedJwt = + typeof jwt === 'string' ? new IdentityServiceToken(jwt) : { payload: jwt }; + const payload = decodedJwt.payload satisfies JwtPayload; + + // For IAS tokens, prefer ias_iss claim over standard iss claim + const subdomain = getIssuerSubdomain(payload, true); + + if (!subdomain) { + logger.warn( + 'Could not extract subdomain from JWT assertion issuer. Falling back to service binding URL.' + ); + return { credentials, subdomain }; + } + + // Replace subdomain in the URL from the service binding + // Reason: We don't want to blindly trust the URL in the assertion + const updatedCredentials = { + ...credentials, + url: replaceSubdomain(credentials.url, subdomain) + }; + + return { credentials: updatedCredentials, subdomain }; +} + /** * @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 jwt - Optional JWT string or payload 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, + jwt?: string | JwtPayload, disableCache: boolean = false ): IdentityService { const serviceConfig = disableCache @@ -40,24 +74,10 @@ export function getIdentityServiceInstanceFromCredentials( : 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.' - ); - } + if (jwt) { + const result = extractSubdomainFromJwt(credentials, jwt); + credentials = result.credentials; + subdomain = result.subdomain; } subdomain = subdomain ?? getIssuerSubdomain({ iss: credentials.url }); diff --git a/packages/connectivity/src/scp-cf/identity-service.spec.ts b/packages/connectivity/src/scp-cf/identity-service.spec.ts index aabdd38471..d43ef445b0 100644 --- a/packages/connectivity/src/scp-cf/identity-service.spec.ts +++ b/packages/connectivity/src/scp-cf/identity-service.spec.ts @@ -601,5 +601,79 @@ describe('getIasToken', () => { // Should handle URLs with and without trailing slashes expect(IdentityService).toHaveBeenCalled(); }); + + it('routes to subscriber tenant for technical user with requestAs="current-tenant"', async () => { + const { IdentityService } = jest.requireMock('@sap/xssec'); + + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + const jwt = { + iss: subscriberUrl, + user_uuid: 'user-123' + }; + + await getIasToken(providerService, { + authenticationType: 'OAuth2ClientCredentials', + requestAs: 'current-tenant', + jwt + }); + + // Should create subscriber instance with subscriber URL + expect(IdentityService).toHaveBeenCalledWith( + expect.objectContaining({ + url: subscriberUrl + }), + undefined + ); + }); + + it('routes to subscriber tenant for technical user when requestAs is undefined and jwt is provided', async () => { + const { IdentityService } = jest.requireMock('@sap/xssec'); + + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + const jwt = { + iss: subscriberUrl, + user_uuid: 'user-123' + }; + + await getIasToken(providerService, { + authenticationType: 'OAuth2ClientCredentials', + jwt + }); + + // Should create subscriber instance with subscriber URL + expect(IdentityService).toHaveBeenCalledWith( + expect.objectContaining({ + url: subscriberUrl + }), + undefined + ); + }); + + it('does not route for technical user with requestAs="provider-tenant"', async () => { + const { IdentityService } = jest.requireMock('@sap/xssec'); + + mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse); + + const jwt = { + iss: subscriberUrl, + user_uuid: 'user-123' + }; + + await getIasToken(providerService, { + authenticationType: 'OAuth2ClientCredentials', + requestAs: 'provider-tenant', + jwt + }); + + // Should use provider instance + expect(IdentityService).toHaveBeenCalledWith( + expect.objectContaining({ + url: providerUrl + }), + undefined + ); + }); }); }); diff --git a/packages/connectivity/src/scp-cf/identity-service.ts b/packages/connectivity/src/scp-cf/identity-service.ts index c2907295d6..5a16f505c1 100644 --- a/packages/connectivity/src/scp-cf/identity-service.ts +++ b/packages/connectivity/src/scp-cf/identity-service.ts @@ -204,9 +204,18 @@ function transformIasOptionsToXssecArgs( * @internal */ async function getIasTokenImpl(arg: IasParameters): Promise { + const jwtForSubdomain = + // For OAuth2JWTBearer authentication, subdomain will be extracted from the assertion + arg.authenticationType === 'OAuth2JWTBearer' + ? arg.assertion + : // For technical user flows, use JWT for subdomain extraction when requesting + // current-tenant context + arg.requestAs !== 'provider-tenant' + ? arg.jwt + : undefined; const identityService = getIdentityServiceInstanceFromCredentials( arg.serviceCredentials, - arg.assertion + jwtForSubdomain ); const tokenOptions = transformIasOptionsToXssecArgs(arg);