Skip to content

Commit

Permalink
feat: improve support for IAS tokens (#4799)
Browse files Browse the repository at this point in the history
* chore: Improve client credentials cache and tenant retrieval

* fix tests

* fix tests

* support IAS tokens

* fix tests

* minor improvement

* Add changelog

* fix public api

* Update packages/connectivity/src/scp-cf/destination/destination-from-service.ts

* Update packages/connectivity/src/scp-cf/destination/destination-from-service.ts

* Update packages/connectivity/src/scp-cf/destination/destination-from-service.ts

Co-authored-by: Matthias Kuhr <[email protected]>

* feat: base token exchange on destination service

* send tenant id with request

* Fixes from review

* fix tests

* Revert "feat: base token exchange on destination service"

This reverts commit 18a2fdc.

* dummy

* fixes after merge

* Changes from lint:fix

* fix doc

* Update packages/connectivity/src/scp-cf/destination/destination-service.ts

* Changes from lint:fix

* remove conditional

* improve function api

* update interface

* remove todo

* Changes from lint:fix

* Update .changeset/empty-eggs-sell.md

* Update packages/connectivity/src/scp-cf/jwt.ts

---------

Co-authored-by: Matthias Kuhr <[email protected]>
Co-authored-by: Tom Frenken <[email protected]>
Co-authored-by: cloud-sdk-js <[email protected]>
Co-authored-by: Tom Frenken <[email protected]>
  • Loading branch information
5 people authored Jul 31, 2024
1 parent afb6cc8 commit 06e5c72
Show file tree
Hide file tree
Showing 20 changed files with 197 additions and 184 deletions.
5 changes: 5 additions & 0 deletions .changeset/empty-eggs-sell.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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
});
Expand All @@ -281,19 +280,21 @@ 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',
iss: onlyIssuerXsuaaUrl,
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);

Expand All @@ -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);

Expand All @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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<
Expand Down
24 changes: 12 additions & 12 deletions packages/connectivity/src/scp-cf/destination/destination-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
)
Expand All @@ -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) {
Expand Down Expand Up @@ -282,7 +282,7 @@ export async function fetchDestinationWithTokenRetrieval(
: authHeader;

return callDestinationEndpoint(
{ uri: targetUri, tenantId: getTenantFromTokens(token) },
{ uri: targetUri, tenantId: getTenantIdFromTokens(token) },
authHeader,
options
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -49,13 +51,11 @@ export function isSubscriberToken(token: any): token is SubscriberToken {
export async function getSubscriberToken(
options: DestinationOptions
): Promise<SubscriberToken> {
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(
Expand All @@ -72,34 +72,37 @@ async function retrieveUserToken(

async function retrieveServiceToken(
options: DestinationOptions,
isXsuaaJwt: boolean
decodedUserJwt: JwtPayload | undefined
): Promise<JwtPair | undefined> {
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;
}
}

Expand Down
Loading

0 comments on commit 06e5c72

Please sign in to comment.