diff --git a/.changeset/empty-eggs-sell.md b/.changeset/empty-eggs-sell.md new file mode 100644 index 0000000000..a51ae52be5 --- /dev/null +++ b/.changeset/empty-eggs-sell.md @@ -0,0 +1,5 @@ +--- +'@sap-cloud-sdk/connectivity': minor +--- + +[New Functionality] Support IAS tokens without the need to pass `iss` in the destination fetch options. 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 115be8043b..416152f73a 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 @@ -155,7 +155,7 @@ function assertMockUsed(mock: nock.Scope, used: boolean) { expect(mock.isDone()).toBe(used); } -describe('jwtType x selection strategy combinations. Possible values are {subscriberUserToken,providerUserToken,noUser} and {alwaysSubscriber, alwaysProvider, subscriberFirst}', () => { +describe('JWT type and selection strategies', () => { beforeEach(() => { mockServiceBindings(); mockVerifyJwt(); @@ -167,8 +167,8 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr jest.clearAllMocks(); }); - describe('userToken x {alwaysSubscriber,alwaysProvider,subscriberFirst}', () => { - it('alwaysSubscriberToken: should not send a request to retrieve remote provider destination and return subscriber destination.', async () => { + describe('user token', () => { + it('alwaysSubscriber: should not send a request to retrieve remote provider destination and return subscriber destination.', async () => { const { subscriberMock, providerMock } = mockDestinationMetadataCalls(); const destination = await fetchDestination( @@ -207,7 +207,7 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr assertMockUsed(providerMock, false); }); - it('subscriberUserToken && subscriberFirst: should try subscriber first (found nothing), provider called and return provider destination', async () => { + it('subscriber user token && subscriberFirst: should try subscriber first (found nothing), provider called and return provider destination', async () => { const [subscriberMock] = mockFetchDestinationCalls(providerDestination); const [providerMock] = mockFetchDestinationCallsNotFound( @@ -226,7 +226,7 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr }); }); - describe('no UserToken x {alwaysSubscriber,alwaysProvider,subscriberFirst}', () => { + describe('no user token', () => { it('retrieves destination without specifying userJwt', async () => { mockServiceBindings(); mockServiceToken(); @@ -268,10 +268,9 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr ).toMatchObject(parseDestination(samlAssertionSingleResponse)); }); - it('is possible to get a non-principal propagation destination by only providing the subdomain (iss) instead of the whole jwt', async () => { + it('gets a non-principal propagation destination when providing `iss` and no JWT', async () => { mockServiceBindings(); mockServiceToken(); - mockFetchDestinationCalls(certificateSingleResponse, { serviceToken: onlyIssuerServiceToken }); @@ -281,6 +280,7 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr messageContext: 'destination-accessor-service' }); const debugSpy = jest.spyOn(logger, 'debug'); + expect( await getDestinationFromDestinationService({ destinationName: 'ERNIE-UND-CERT', @@ -288,12 +288,13 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr cacheVerificationKeys: false }) ).toMatchObject(parseDestination(certificateSingleResponse)); + expect(debugSpy).toHaveBeenCalledWith( 'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.' ); }); - it('no user token && alwaysSubscriber: should return null since the token does not match subscriber', async () => { + it('alwaysSubscriber: should return null since the token does not match subscriber', async () => { const { subscriberMock, providerMock } = mockDestinationMetadataCalls(); const destination = await fetchDestination(undefined, alwaysSubscriber); @@ -302,7 +303,7 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr assertMockUsed(providerMock, false); }); - it('no user token && alwaysProvider: should not send a request to retrieve remote subscriber destination and return provider destination.', async () => { + it('alwaysProvider: should not send a request to retrieve remote subscriber destination and return provider destination.', async () => { const { subscriberMock, providerMock } = mockDestinationMetadataCalls(); const destination = await fetchDestination(undefined, alwaysProvider); @@ -311,7 +312,7 @@ describe('jwtType x selection strategy combinations. Possible values are {subscr assertMockUsed(providerMock, true); }); - it('no user token && subscriberFirst: should not send a request to retrieve remote subscriber destination and return provider destination.', async () => { + it('subscriberFirst: should not send a request to retrieve remote subscriber destination and return provider destination.', async () => { const { subscriberMock, providerMock } = mockDestinationMetadataCalls(); const destination = await fetchDestination(undefined, subscriberFirst); diff --git a/packages/connectivity/src/scp-cf/destination/destination-accessor.ts b/packages/connectivity/src/scp-cf/destination/destination-accessor.ts index ae291b5d11..290008729c 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-accessor.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-accessor.ts @@ -1,7 +1,7 @@ import { createLogger, ErrorWithCause } from '@sap-cloud-sdk/util'; import { exchangeToken, shouldExchangeToken } from '../identity-service'; -import { getIssuerSubdomain } from '../subdomain-replacer'; import { getDestinationServiceCredentials } from '../environment-accessor'; +import { getSubdomain } from '../jwt'; import { DestinationOrFetchOptions, sanitizeDestination, @@ -138,9 +138,9 @@ export async function getAllDestinationsFromDestinationService( (await getProviderServiceToken(options)); const destinationServiceUri = getDestinationServiceCredentials().uri; - const accountName = getIssuerSubdomain(token.decoded); + const subdomain = getSubdomain(token.decoded); logger.debug( - `Retrieving all destinations for account: "${accountName}" from destination service.` + `Retrieving all destinations for account: "${subdomain}" from destination service.` ); const [instance, subaccount] = await Promise.all([ @@ -168,7 +168,7 @@ export async function getAllDestinationsFromDestinationService( if (allDestinations?.length) { logger.debug( - `Successfully retrieved all destinations for account: "${accountName}" from destination service.` + `Successfully retrieved all destinations for account: "${subdomain}" from destination service.` ); } else { logger.debug("Didn't receive any destinations from destination service."); diff --git a/packages/connectivity/src/scp-cf/destination/destination-from-service.ts b/packages/connectivity/src/scp-cf/destination/destination-from-service.ts index 38054098fa..eac0e6d479 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-from-service.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-from-service.ts @@ -6,10 +6,9 @@ import { getServiceBinding } from '../environment-accessor'; import { exchangeToken, shouldExchangeToken } from '../identity-service'; -import { JwtPair } from '../jwt'; +import { JwtPair, getSubdomain, isXsuaaToken } from '../jwt'; import { isIdenticalTenant } from '../tenant'; import { jwtBearerToken } from '../token-accessor'; -import { getIssuerSubdomain } from '../subdomain-replacer'; import { DestinationFetchOptions, DestinationsByType @@ -241,12 +240,10 @@ export class DestinationFromServiceRetriever { if (destination.originalProperties?.['tokenServiceURLType'] !== 'Common') { return undefined; } - const subdomainSubscriber = getIssuerSubdomain( - this.subscriberToken?.userJwt?.decoded - ); - const subdomainProvider = getIssuerSubdomain( - this.providerServiceToken?.decoded - ); + const subdomainSubscriber = + getSubdomain(this.subscriberToken?.serviceJwt?.decoded) || + getSubdomain(this.subscriberToken?.userJwt?.decoded); + const subdomainProvider = getSubdomain(this.providerServiceToken?.decoded); return subdomainSubscriber || subdomainProvider || undefined; } @@ -299,14 +296,16 @@ Possible alternatives for such technical user authentication are BasicAuthentica // This covers OAuth to user-dependent auth flows https://help.sap.com/viewer/cca91383641e40ffbe03bdc78f00f681/Cloud/en-US/39d42654093e4f8db20398a06f7eab2b.html and https://api.sap.com/api/SAP_CP_CF_Connectivity_Destination/resource // Which is the same for: OAuth2UserTokenExchange, OAuth2JWTBearer and OAuth2SAMLBearerAssertion - // If subscriber token does not include service JWT (aka. originally passed JWT was not an XSUAA JWT) enforce the JWKS properties are there - destination service would do that as well. https://help.sap.com/docs/CP_CONNECTIVITY/cca91383641e40ffbe03bdc78f00f681/d81e1683bd434823abf3ceefc4ff157f.html - if (!this.subscriberToken.serviceJwt) { + const isXsuaaUserJwt = isXsuaaToken(this.subscriberToken.userJwt.decoded); + // If subscriber user token was not issued by XSUAA enforce the JWKS properties are there - destination service would do that as well. https://help.sap.com/docs/CP_CONNECTIVITY/cca91383641e40ffbe03bdc78f00f681/d81e1683bd434823abf3ceefc4ff157f.html + if (!isXsuaaUserJwt) { DestinationFromServiceRetriever.checkDestinationForCustomJwt(destination); } - // Case 1 Destination in provider and JWT issued for provider account, but no custom JWT given -> no extra x-user-token header needed + // Case 1: subscriber account is the provider account, user JWT is from XSUAA + // x-user-token header not needed if ( - this.subscriberToken.serviceJwt && + isXsuaaUserJwt && isIdenticalTenant( this.subscriberToken.userJwt.decoded, this.providerServiceToken.decoded @@ -323,11 +322,13 @@ Possible alternatives for such technical user authentication are BasicAuthentica }; } - // Case 2 Subscriber and provider account not the same OR custom JWT -> x-user-token header passed to determine user and tenant in token service URL and service token to get the destination + // Case 2a: subscriber and provider account not the same + // Case 2b: user token is not an XSUAA token + // x-user-token needed const serviceJwt = origin === 'provider' ? this.providerServiceToken - : // TODO: What is the meaning of this? Why do we assume this is defined. Technically, it might not be. + : // on type level this could be undefined, but logically if the origin is subscriber, it must be defined. this.subscriberToken.serviceJwt!; logger.debug( @@ -544,21 +545,13 @@ Possible alternatives for such technical user authentication are BasicAuthentica } private isSubscriberNeeded(): boolean { - if (!this.subscriberToken) { - return false; - } - - if (!this.subscriberToken.serviceJwt) { - return false; - } - - if ( - this.options.selectionStrategy.toString() === alwaysProvider.toString() - ) { + if (!this.subscriberToken?.serviceJwt) { return false; } - return true; + return ( + this.options.selectionStrategy.toString() !== alwaysProvider.toString() + ); } private async searchProviderAccountForDestination(): Promise< diff --git a/packages/connectivity/src/scp-cf/destination/destination-service.ts b/packages/connectivity/src/scp-cf/destination/destination-service.ts index 60384e4c92..f1a196daac 100644 --- a/packages/connectivity/src/scp-cf/destination/destination-service.ts +++ b/packages/connectivity/src/scp-cf/destination/destination-service.ts @@ -13,10 +13,9 @@ import { MiddlewareContext } from '@sap-cloud-sdk/resilience'; import * as asyncRetry from 'async-retry'; -import { decodeJwt, wrapJwtInHeader } from '../jwt'; +import { decodeJwt, getTenantId, wrapJwtInHeader } from '../jwt'; import { urlAndAgent } from '../../http-agent'; import { buildAuthorizationHeaders } from '../authorization-header'; -import { getTenantIdWithFallback } from '../tenant'; import { DestinationConfiguration, DestinationJson, @@ -80,7 +79,7 @@ export async function fetchDestinations( const headers = wrapJwtInHeader(serviceToken).headers; return callDestinationEndpoint( - { uri: targetUri, tenantId: getTenantFromTokens(serviceToken) }, + { uri: targetUri, tenantId: getTenantIdFromTokens(serviceToken) }, headers ) .then(response => { @@ -148,7 +147,7 @@ export async function fetchDestinationWithoutTokenRetrieval( try { const response = await callDestinationEndpoint( - { uri: targetUri, tenantId: getTenantFromTokens(serviceToken) }, + { uri: targetUri, tenantId: getTenantIdFromTokens(serviceToken) }, { Authorization: `Bearer ${serviceToken}` } ); const destination = parseDestination( @@ -211,13 +210,13 @@ export async function fetchCertificate( try { const response = await callCertificateEndpoint( - { uri: accountUri, tenantId: getTenantFromTokens(token) }, + { uri: accountUri, tenantId: getTenantIdFromTokens(token) }, header ).catch(() => callCertificateEndpoint( { uri: instanceUri, - tenantId: getTenantFromTokens(token) + tenantId: getTenantIdFromTokens(token) }, header ) @@ -231,15 +230,16 @@ export async function fetchCertificate( } } -function getTenantFromTokens(token: AuthAndExchangeTokens | string): string { +function getTenantIdFromTokens(token: AuthAndExchangeTokens | string): string { let tenant: string | undefined; if (typeof token === 'string') { - tenant = getTenantIdWithFallback(token); + tenant = getTenantId(token); } else { tenant = - token.exchangeTenant || // represents the tenant as string already see https://api.sap.com/api/SAP_CP_CF_Connectivity_Destination/resource - getTenantIdWithFallback(token.exchangeHeaderJwt) || - getTenantIdWithFallback(token.authHeaderJwt); + // represents the tenant as string already see https://api.sap.com/api/SAP_CP_CF_Connectivity_Destination/resource + token.exchangeTenant || + getTenantId(token.exchangeHeaderJwt) || + getTenantId(token.authHeaderJwt); } if (!tenant) { @@ -282,7 +282,7 @@ export async function fetchDestinationWithTokenRetrieval( : authHeader; return callDestinationEndpoint( - { uri: targetUri, tenantId: getTenantFromTokens(token) }, + { uri: targetUri, tenantId: getTenantIdFromTokens(token) }, authHeader, options ) diff --git a/packages/connectivity/src/scp-cf/destination/get-subscriber-token.spec.ts b/packages/connectivity/src/scp-cf/destination/get-subscriber-token.spec.ts index b87abe8960..18322481a0 100644 --- a/packages/connectivity/src/scp-cf/destination/get-subscriber-token.spec.ts +++ b/packages/connectivity/src/scp-cf/destination/get-subscriber-token.spec.ts @@ -70,7 +70,7 @@ describe('getSubscriberToken()', () => { expect(serviceTokenSpy).toHaveBeenCalledWith( 'destination', expect.objectContaining({ - jwt: { iss: onlyIssuerXsuaaUrl } + jwt: { ext_attr: { zdn: 'subscriber-only-iss' } } }) ); expect(verifyJwtSpy).not.toHaveBeenCalled(); diff --git a/packages/connectivity/src/scp-cf/destination/get-subscriber-token.ts b/packages/connectivity/src/scp-cf/destination/get-subscriber-token.ts index d03e18043e..15237c0fbd 100644 --- a/packages/connectivity/src/scp-cf/destination/get-subscriber-token.ts +++ b/packages/connectivity/src/scp-cf/destination/get-subscriber-token.ts @@ -1,12 +1,14 @@ import { createLogger } from '@sap-cloud-sdk/util'; +import { JwtPayload } from 'jsonwebtoken'; import { - decodeJwtComplete, + decodeJwt, getJwtPair, isXsuaaToken, JwtPair, verifyJwt } from '../jwt'; import { serviceToken } from '../token-accessor'; +import { getIssuerSubdomain } from '../subdomain-replacer'; import { DestinationOptions } from './destination-accessor-types'; const logger = createLogger({ @@ -49,13 +51,11 @@ export function isSubscriberToken(token: any): token is SubscriberToken { export async function getSubscriberToken( options: DestinationOptions ): Promise { - const isXsuaaJwt = - !!options.jwt && isXsuaaToken(decodeJwtComplete(options.jwt)); + const isXsuaaJwt = !!options.jwt && isXsuaaToken(decodeJwt(options.jwt)); + const userJwt = await retrieveUserToken(options, isXsuaaJwt); + const serviceJwt = await retrieveServiceToken(options, userJwt?.decoded); - return { - userJwt: await retrieveUserToken(options, isXsuaaJwt), - serviceJwt: await retrieveServiceToken(options, isXsuaaJwt) - }; + return { userJwt, serviceJwt }; } async function retrieveUserToken( @@ -72,34 +72,37 @@ async function retrieveUserToken( async function retrieveServiceToken( options: DestinationOptions, - isXsuaaJwt: boolean + decodedUserJwt: JwtPayload | undefined ): Promise { - const jwt = getJwtForServiceToken(options, isXsuaaJwt); + const jwt = getJwtForServiceToken(options.iss, decodedUserJwt); if (jwt) { - return getJwtPair( - await serviceToken('destination', { - ...options, - jwt - }) - ); + try { + return getJwtPair( + await serviceToken('destination', { + ...options, + jwt + }) + ); + } catch (err) { + logger.warn( + `Failed to fetch subscriber service token for destination. This is only relevant if you are using subscriber destinations. Failure caused by: ${err.message}` + ); + } } } -function getJwtForServiceToken( - options: DestinationOptions, - isXsuaaJwt: boolean -) { - if (options.iss) { +function getJwtForServiceToken(iss?: string, decodedUserJwt?: JwtPayload) { + if (iss) { logger.debug( 'Using `iss` option instead of a full JWT to fetch a destination. No validation is performed.' ); - return { iss: options.iss }; + return { ext_attr: { zdn: getIssuerSubdomain({ iss }) } }; } - if (options.jwt && isXsuaaJwt) { - return options.jwt; + if (decodedUserJwt?.zid || decodedUserJwt?.app_tid) { + return decodedUserJwt; } } diff --git a/packages/connectivity/src/scp-cf/identity-service.ts b/packages/connectivity/src/scp-cf/identity-service.ts index b181707326..92843585cf 100644 --- a/packages/connectivity/src/scp-cf/identity-service.ts +++ b/packages/connectivity/src/scp-cf/identity-service.ts @@ -1,7 +1,7 @@ import { createSecurityContext } from '@sap/xssec'; import { DestinationOptions } from './destination'; import { getXsuaaService } from './environment-accessor'; -import { decodeJwtComplete, isXsuaaToken } from './jwt'; +import { decodeJwt, isXsuaaToken } from './jwt'; /** * @internal @@ -32,6 +32,6 @@ export function shouldExchangeToken(options: DestinationOptions): boolean { return ( options.iasToXsuaaTokenExchange !== false && !!options.jwt && - !isXsuaaToken(decodeJwtComplete(options.jwt)) + !isXsuaaToken(decodeJwt(options.jwt)) ); } diff --git a/packages/connectivity/src/scp-cf/jwt.spec.ts b/packages/connectivity/src/scp-cf/jwt.spec.ts index ee0f1392c4..f054f5699c 100644 --- a/packages/connectivity/src/scp-cf/jwt.spec.ts +++ b/packages/connectivity/src/scp-cf/jwt.spec.ts @@ -13,11 +13,11 @@ import { } from '../../../../test-resources/test/test-util'; import { audiences, - decodeJwtComplete, retrieveJwt, verifyJwt, isXsuaaToken, - decodeOrMakeJwt + decodeOrMakeJwt, + decodeJwt } from './jwt'; import { clearXsuaaServices } from './environment-accessor'; @@ -49,14 +49,14 @@ export function responseWithPublicKey(key: string = publicKey) { describe('jwt', () => { describe('isXsuaaToken()', () => { it('returns true if the token was issued by XSUAA', () => { - const jwt = decodeJwtComplete( + const jwt = decodeJwt( signedJwtForVerification({ ext_attr: { enhancer: 'XSUAA' } }) ); expect(isXsuaaToken(jwt)).toBe(true); }); it('returns false if the token was not issued XSUAA', () => { - const jwt = decodeJwtComplete( + const jwt = decodeJwt( signedJwtForVerification({ ext_attr: { enhancer: 'IAS' } }) ); mockServiceBindings({ xsuaaBinding: false }); @@ -64,7 +64,7 @@ describe('jwt', () => { }); it('returns false if no enhancer is set', () => { - const jwt = decodeJwtComplete(signedJwtForVerification({})); + const jwt = decodeJwt(signedJwtForVerification({})); expect(isXsuaaToken(jwt)).toBe(false); }); }); diff --git a/packages/connectivity/src/scp-cf/jwt.ts b/packages/connectivity/src/scp-cf/jwt.ts index 3f71db6c2f..c0f0d0454f 100644 --- a/packages/connectivity/src/scp-cf/jwt.ts +++ b/packages/connectivity/src/scp-cf/jwt.ts @@ -10,6 +10,7 @@ import { Cache } from './cache'; import { getServiceCredentials, getXsuaaService } from './environment-accessor'; import { Jwt, JwtPayload, JwtWithPayloadObject } from './jsonwebtoken-type'; import { TokenKey } from './xsuaa-service-types'; +import { getIssuerSubdomain } from './subdomain-replacer'; const logger = createLogger({ package: 'connectivity', @@ -31,17 +32,36 @@ export function userId({ user_id }: JwtPayload): string { return user_id; } -/* eslint-disable jsdoc/check-param-names, jsdoc/require-param */ /** * Get the tenant ID of a decoded JWT, based on its `zid` or if not available `app_tid` property. - * @param jwtPayload - Token payload to read the tenant ID from. + * @param jwt - Token to read the tenant ID from. * @returns The tenant ID, if available. */ -export function getTenantId({ zid, app_tid }: JwtPayload): string { - logger.debug(`JWT zid is: ${zid}, app_tid is: ${app_tid}.`); - return zid ?? app_tid; +export function getTenantId( + jwt: JwtPayload | string | undefined +): string | undefined { + const decodedJwt = jwt ? decodeJwt(jwt) : {}; + logger.debug( + `JWT zid is: ${decodedJwt.zid}, app_tid is: ${decodedJwt.app_tid}.` + ); + return decodedJwt.zid || decodedJwt.app_tid || undefined; +} + +/** + * @internal + * Retrieve the subdomain from the decoded XSUAA JWT. If the JWT is not in XSUAA format, returns `undefined`. + * @param jwt - JWT to retrieve the subdomain from. + * @returns The subdomain, if available. + */ +export function getSubdomain( + jwt: JwtPayload | string | undefined +): string | undefined { + const decodedJwt = jwt ? decodeJwt(jwt) : {}; + return ( + decodedJwt?.ext_attr?.zdn || + (isXsuaaToken(decodedJwt) ? getIssuerSubdomain(decodedJwt) : undefined) + ); } -/* eslint-enable jsdoc/check-param-names, jsdoc/require-param */ /** * @internal @@ -197,12 +217,12 @@ export function wrapJwtInHeader(token: string): { /** * Checks if the given JWT was issued by XSUAA based on the `iss` property and the UAA domain of the XSUAA. - * @param jwt - JWT to be checked. + * @param decodedJwt - JWT to be checked. * @returns Whether the JWT was issued by XSUAA. * @internal */ -export function isXsuaaToken(jwt: JwtWithPayloadObject): boolean { - return jwt.payload.ext_attr?.enhancer === 'XSUAA'; +export function isXsuaaToken(decodedJwt: JwtPayload | undefined): boolean { + return decodedJwt?.ext_attr?.enhancer === 'XSUAA'; } /** diff --git a/packages/connectivity/src/scp-cf/tenant.spec.ts b/packages/connectivity/src/scp-cf/tenant.spec.ts deleted file mode 100644 index 796db43cc3..0000000000 --- a/packages/connectivity/src/scp-cf/tenant.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { signedJwt } from '../../../../test-resources/test/test-util'; -import { getTenantIdWithFallback } from './tenant'; - -describe('tenant builder from JWT', () => { - describe('getTenantIdWithFallback', () => { - afterEach(() => { - delete process.env['VCAP_SERVICES']; - }); - - it('returns the `zid` from a JWT, if present', () => { - expect( - getTenantIdWithFallback({ user_id: 'user', zid: 'tenant' }) - ).toEqual('tenant'); - }); - - it('returns subdomain of `iss` from a JWT, if present', () => { - expect( - getTenantIdWithFallback({ - user_id: 'user', - iss: 'http://dummy-iss.com' - }) - ).toEqual('dummy-iss'); - }); - - it('returns undefined for tenantid when custom JWT contains neither `zid` nor `iss`', () => { - expect(getTenantIdWithFallback({ user_id: 'user' })).toBeUndefined(); - }); - - it('returns the `zid` from am encoded JWT', () => { - expect( - getTenantIdWithFallback(signedJwt({ user_id: 'user', zid: 'tenant' })) - ).toEqual('tenant'); - }); - }); -}); diff --git a/packages/connectivity/src/scp-cf/tenant.ts b/packages/connectivity/src/scp-cf/tenant.ts index 2dc5e21e08..3c8e27d227 100644 --- a/packages/connectivity/src/scp-cf/tenant.ts +++ b/packages/connectivity/src/scp-cf/tenant.ts @@ -1,19 +1,5 @@ import { JwtPayload } from './jsonwebtoken-type'; -import { decodeJwt, getTenantId } from './jwt'; -import { getIssuerSubdomain } from './subdomain-replacer'; - -/** - * Get the tenant ID of a decoded JWT, based on its `zid` property or, if not available, the `iss` subdomain. - * @param token - Token to read the tenant ID from. - * @returns The tenant ID, if available. - * @internal - */ -export function getTenantIdWithFallback( - token: string | JwtPayload | undefined -): string | undefined { - const decodedJwt = token ? decodeJwt(token) : {}; - return getTenantId(decodedJwt) || getIssuerSubdomain(decodedJwt) || undefined; -} +import { getTenantId } from './jwt'; /** * Compare two decoded JWTs based on their `tenantId`s. diff --git a/packages/connectivity/src/scp-cf/token-accessor.spec.ts b/packages/connectivity/src/scp-cf/token-accessor.spec.ts index 4a4ce1b564..d59fe972a0 100644 --- a/packages/connectivity/src/scp-cf/token-accessor.spec.ts +++ b/packages/connectivity/src/scp-cf/token-accessor.spec.ts @@ -10,7 +10,10 @@ import { testTenants, uaaDomain } from '../../../../test-resources/test/test-util/environment-mocks'; -import { signedJwt } from '../../../../test-resources/test/test-util/keys'; +import { + signedJwt, + signedXsuaaJwt +} from '../../../../test-resources/test/test-util/keys'; import { providerServiceToken, providerUserPayload, @@ -59,9 +62,10 @@ describe('token accessor', () => { it('considers default resilience middlewares for client credentials token', async () => { const spy = jest.spyOn(resilience, 'resilience'); - const jwt = signedJwt({ + const jwt = signedXsuaaJwt({ iss: 'https://testeroni.example.com' }); + mockClientCredentialsGrantCall( `https://testeroni.${uaaDomain}`, { access_token: 'testValue' }, @@ -75,21 +79,38 @@ describe('token accessor', () => { expect(spy).toHaveBeenCalledWith(); }); - it("uses the JWT's issuer as tenant", async () => { - const expected = signedJwt({ dummy: 'content' }); - const jwt = signedJwt({ + it('uses the subdomain of the JWT as tenant', async () => { + const accessToken = signedJwt({ dummy: 'content' }); + const jwt = signedXsuaaJwt({ + ext_attr: { zdn: 'testeroni' } + }); + + mockClientCredentialsGrantCall( + `https://testeroni.${uaaDomain}`, + { access_token: accessToken }, + 200, + destinationBindingClientSecretMock.credentials + ); + + const actual = await serviceToken('destination', { jwt }); + expect(actual).toBe(accessToken); + }); + + it('uses the issuer of the XSUAA JWT as tenant', async () => { + const accessToken = signedJwt({ dummy: 'content' }); + const jwt = signedXsuaaJwt({ iss: 'https://testeroni.example.com' }); mockClientCredentialsGrantCall( `https://testeroni.${uaaDomain}`, - { access_token: expected }, + { access_token: accessToken }, 200, destinationBindingClientSecretMock.credentials ); const actual = await serviceToken('destination', { jwt }); - expect(actual).toBe(expected); + expect(actual).toBe(accessToken); }); it('authenticates with certificate', async () => { @@ -168,7 +189,7 @@ describe('token accessor', () => { ); mockClientCredentialsGrantCall( - subscriberXsuaaUrl, + providerXsuaaUrl, { access_token: subscriberServiceToken }, 200, destinationBindingClientSecretMock.credentials, diff --git a/packages/connectivity/src/scp-cf/token-accessor.ts b/packages/connectivity/src/scp-cf/token-accessor.ts index ac3bc5c41e..e3ac6e27cb 100644 --- a/packages/connectivity/src/scp-cf/token-accessor.ts +++ b/packages/connectivity/src/scp-cf/token-accessor.ts @@ -1,6 +1,6 @@ import { ErrorWithCause } from '@sap-cloud-sdk/util'; import { JwtPayload } from './jsonwebtoken-type'; -import { getTenantIdFromBinding } from './jwt'; +import { getSubdomain, getTenantId, getTenantIdFromBinding } from './jwt'; import { CachingOptions } from './cache'; import { clientCredentialsTokenCache } from './client-credentials-token-cache'; import { resolveServiceBinding } from './environment-accessor'; @@ -9,7 +9,6 @@ import { XsuaaServiceCredentials } from './environment-accessor/environment-accessor-types'; import { getClientCredentialsToken, getUserToken } from './xsuaa-service'; -import { getTenantIdWithFallback } from './tenant'; /** * Returns an access token that can be used to call the given service. The token is fetched via a client credentials grant with the credentials of the given service. @@ -38,13 +37,13 @@ export async function serviceToken( const serviceBinding = resolveServiceBinding(service); const serviceCredentials = serviceBinding.credentials; - const tenant = options?.jwt - ? getTenantIdWithFallback(options?.jwt) + const tenantForCaching = options?.jwt + ? getTenantId(options.jwt) || getSubdomain(options.jwt) : getTenantIdFromBinding(); if (opts.useCache) { const cachedToken = clientCredentialsTokenCache.getToken( - tenant, + tenantForCaching, serviceCredentials.clientid ); if (cachedToken) { @@ -57,7 +56,7 @@ export async function serviceToken( if (opts.useCache) { clientCredentialsTokenCache.cacheToken( - tenant, + tenantForCaching, serviceCredentials.clientid, token ); diff --git a/packages/connectivity/src/scp-cf/xsuaa-service.ts b/packages/connectivity/src/scp-cf/xsuaa-service.ts index 9c011bf18e..2608d39fe6 100644 --- a/packages/connectivity/src/scp-cf/xsuaa-service.ts +++ b/packages/connectivity/src/scp-cf/xsuaa-service.ts @@ -7,12 +7,11 @@ import { } from './environment-accessor/environment-accessor-types'; import { ClientCredentialsResponse } from './xsuaa-service-types'; import { getXsuaaService, resolveServiceBinding } from './environment-accessor'; -import { getIssuerSubdomain } from './subdomain-replacer'; -import { decodeJwt, getTenantId } from './jwt'; +import { decodeJwt, getSubdomain, getTenantId } from './jwt'; interface XsuaaParameters { - subdomain: string | null; - zoneId: string | null; + subdomain?: string; + zoneId?: string; serviceCredentials: ServiceCredentials; userJwt?: string; } @@ -20,17 +19,17 @@ interface XsuaaParameters { /** * Make a client credentials request against the XSUAA service. * @param service - Service as it is defined in the environment variable. - * @param userJwt - User JWT. + * @param jwt - User JWT or object containing the `iss` property. * @returns Client credentials token. */ export async function getClientCredentialsToken( service: string | Service, - userJwt?: string | JwtPayload + jwt?: string | JwtPayload ): Promise { - const jwt = userJwt ? decodeJwt(userJwt) : {}; + const decodedJwt = jwt ? decodeJwt(jwt) : {}; const fnArgument: XsuaaParameters = { - subdomain: getIssuerSubdomain(jwt) || null, - zoneId: getTenantId(jwt) || null, + subdomain: getSubdomain(decodedJwt), + zoneId: getTenantId(decodedJwt), serviceCredentials: resolveServiceBinding(service).credentials }; @@ -41,7 +40,8 @@ export async function getClientCredentialsToken( return xsuaaService.fetchClientCredentialsToken({ // tenant is the subdomain, not tenant ID - tenant: arg.subdomain + tenant: arg.zoneId ? undefined : arg.subdomain, + zid: arg.zoneId }); }; @@ -75,10 +75,10 @@ export function getUserToken( service: Service, userJwt: string ): Promise { - const jwt = decodeJwt(userJwt); + const decodedUserJwt = decodeJwt(userJwt); const fnArgument: XsuaaParameters = { - subdomain: getIssuerSubdomain(jwt) || null, - zoneId: getTenantId(jwt) || null, + subdomain: getSubdomain(decodedUserJwt), + zoneId: getTenantId(decodedUserJwt), serviceCredentials: service.credentials, userJwt }; @@ -90,7 +90,8 @@ export function getUserToken( return xsuaaService .fetchJwtBearerToken(arg.userJwt, { // tenant is the subdomain, not tenant ID - tenant: arg.subdomain + tenant: arg.zoneId ? undefined : arg.subdomain, + zid: arg.zoneId }) .then(token => token.access_token); }; diff --git a/packages/http-client/src/http-client.spec.ts b/packages/http-client/src/http-client.spec.ts index d5b2687c17..3427103c5b 100644 --- a/packages/http-client/src/http-client.spec.ts +++ b/packages/http-client/src/http-client.spec.ts @@ -35,7 +35,6 @@ import { providerXsuaaUrl, subscriberServiceToken, subscriberUserToken, - subscriberXsuaaUrl, testTenants, xsuaaBindingMock } from '../../../test-resources/test/test-util'; @@ -250,10 +249,12 @@ describe('generic http client', () => { .get('') .query({ zid: testTenants.subscriber }) .reply(200, responseWithPublicKey()); + nock(jku) .get('') .query({ zid: testTenants.provider }) .reply(200, responseWithPublicKey()); + mockUserTokenGrantCall( providerXsuaaUrl, 1, @@ -261,19 +262,21 @@ describe('generic http client', () => { subscriberUserToken, xsuaaBindingMock.credentials ); + mockClientCredentialsGrantCall( providerXsuaaUrl, - { access_token: providerServiceToken }, - 200, - destinationBindingClientSecretMock.credentials - ); - mockClientCredentialsGrantCall( - subscriberXsuaaUrl, { access_token: subscriberServiceToken }, 200, destinationBindingClientSecretMock.credentials, testTenants.subscriber ); + + mockClientCredentialsGrantCall( + providerXsuaaUrl, + { access_token: providerServiceToken }, + 200, + destinationBindingClientSecretMock.credentials + ); } it('passes the context properties to the middleware', async () => { @@ -300,6 +303,7 @@ describe('generic http client', () => { method: 'get' } ); + delete process.env['VCAP_SERVICES']; nock.cleanAll(); jest.clearAllMocks(); diff --git a/packages/http-client/src/http-client.ts b/packages/http-client/src/http-client.ts index 9139a0204f..e2373e5f85 100644 --- a/packages/http-client/src/http-client.ts +++ b/packages/http-client/src/http-client.ts @@ -4,7 +4,8 @@ import { buildHeadersForDestination, Destination, HttpDestinationOrFetchOptions, - getAgentConfigAsync + getAgentConfigAsync, + getTenantId } from '@sap-cloud-sdk/connectivity'; import { assertHttpDestination, @@ -12,7 +13,6 @@ import { getAdditionalHeaders, getAdditionalQueryParameters, getProxyConfig, - getTenantIdWithFallback, HttpDestination, resolveDestination } from '@sap-cloud-sdk/connectivity/internal'; @@ -108,7 +108,7 @@ export function execute(executeFn: ExecuteHttpRequestFn) { jwt: destination.jwt, uri: resolvedDestination.url, destinationName: resolvedDestination.name ?? undefined, - tenantId: getTenantIdWithFallback(destination.jwt) + tenantId: getTenantId(destination.jwt) } }); }; diff --git a/test-resources/test/test-util/keys.ts b/test-resources/test/test-util/keys.ts index 6aa3d07040..6c892da45a 100644 --- a/test-resources/test/test-util/keys.ts +++ b/test-resources/test/test-util/keys.ts @@ -1,5 +1,5 @@ -import { Algorithm, JwtHeader, sign } from 'jsonwebtoken'; import { generateKeyPairSync } from 'node:crypto'; +import { Algorithm, JwtHeader, sign } from 'jsonwebtoken'; export const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 4096, @@ -22,6 +22,16 @@ export function signedJwt(payload, algorithm: Algorithm = 'RS512') { }); } +export function signedXsuaaJwt(payload, algorithm: Algorithm = 'RS512') { + return sign( + { ...payload, ext_attr: { ...payload.ext_attr, enhancer: 'XSUAA' } }, + privateKey, + { + algorithm + } + ); +} + export function signedJwtForVerification( payload: string | object | Buffer, jku: string | null = defaultJku, diff --git a/test-resources/test/test-util/token-accessor-mocks.ts b/test-resources/test/test-util/token-accessor-mocks.ts index f9790dcb6b..7fbaf340a1 100644 --- a/test-resources/test/test-util/token-accessor-mocks.ts +++ b/test-resources/test/test-util/token-accessor-mocks.ts @@ -1,6 +1,7 @@ import nock from 'nock'; import * as tokenAccessor from '@sap-cloud-sdk/connectivity/src/scp-cf/token-accessor'; import { decodeJwt } from '@sap-cloud-sdk/connectivity'; +import { isXsuaaToken } from '@sap-cloud-sdk/connectivity/internal'; import { onlyIssuerXsuaaUrl, testTenants } from './environment-mocks'; import { onlyIssuerServiceToken, @@ -26,7 +27,10 @@ export function mockServiceToken() { const userJwt = typeof options.jwt === 'string' ? decodeJwt(options.jwt) : options.jwt; - if (userJwt.iss === onlyIssuerXsuaaUrl) { + if ( + userJwt.ext_attr.zdn === testTenants.subscriberOnlyIss || + (isXsuaaToken(userJwt) && userJwt.iss === onlyIssuerXsuaaUrl) + ) { return Promise.resolve(onlyIssuerServiceToken); } diff --git a/test-resources/test/test-util/xsuaa-service-mocks.ts b/test-resources/test/test-util/xsuaa-service-mocks.ts index 0b79c9c336..5ecd092228 100644 --- a/test-resources/test/test-util/xsuaa-service-mocks.ts +++ b/test-resources/test/test-util/xsuaa-service-mocks.ts @@ -11,7 +11,8 @@ export function mockClientCredentialsGrantCall( delay = 0 ) { return nock(uri, { - reqheaders: xsuaaRequestHeaders() + reqheaders: xsuaaRequestHeaders(zoneId ? { 'x-zid': zoneId } : {}), + badheaders: zoneId ? [] : ['x-zid'] }) .post('/oauth/token', { grant_type: 'client_credentials', @@ -30,7 +31,7 @@ export function mockClientCredentialsGrantWithCertCall( zoneId?: string ) { return nock(uri, { - reqheaders: xsuaaRequestHeaders() + reqheaders: xsuaaRequestHeaders(zoneId ? { zid: zoneId } : {}) }) .post('/oauth/token', { grant_type: 'client_credentials',