Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
90a2f9a
wip
davidkna-sap Dec 1, 2025
b2d80f4
support reading user_uuid from jwtPayload
davidkna-sap Dec 1, 2025
0ed94aa
add extraParams option field
davidkna-sap Dec 1, 2025
5ec4657
Changes from lint:fix
Dec 2, 2025
a541e18
add workaround
davidkna-sap Dec 4, 2025
24cd0a7
chore: add targetURL option to `iasBindingToDestination`
davidkna-sap Dec 4, 2025
1348182
chore: fix clientsecret auth
davidkna-sap Dec 5, 2025
2f2cbf6
rename options & forward mtls creds to destination
davidkna-sap Dec 8, 2025
f9ce3a5
chore: add changeset
davidkna-sap Dec 8, 2025
9990c6f
chore: update changeset msg
davidkna-sap Dec 8, 2025
a146af2
chore: print warning if both `mtls` and `mtlsKeyPair` provided
davidkna-sap Dec 9, 2025
6ab8a1f
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 10, 2025
e5e67eb
move ias-specific opts to iasOptions & refactor parameter handling
davidkna-sap Dec 10, 2025
cd8e736
increase resource name flexibility
davidkna-sap Dec 10, 2025
34b83be
chore: try to fix lint error
davidkna-sap Dec 10, 2025
fdb3d35
support user-token exchange
davidkna-sap Dec 12, 2025
af1d0bd
add experimental to changelog note
davidkna-sap Dec 12, 2025
62d4023
add initial cache implementation
davidkna-sap Dec 12, 2025
437fd87
harmonize towards `AuthenticationType` and allow disabling cache
davidkna-sap Dec 15, 2025
de930e4
harmonize ias/xsuaa caching
davidkna-sap Dec 15, 2025
fbcca4b
use xssec as the base implementation
davidkna-sap Dec 16, 2025
e71c268
make codeql happy
davidkna-sap Dec 16, 2025
738d697
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 16, 2025
4f925f5
handle multi-resource caching
davidkna-sap Dec 16, 2025
4157094
allow listing multiple IasResource in Service
davidkna-sap Dec 16, 2025
e60cd48
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 17, 2025
41dde3c
remove iasResource from Service interface
davidkna-sap Dec 17, 2025
a3a8b5e
move IAS-specific options for serviceToken & jwtBearerToken to iasOpt…
davidkna-sap Dec 17, 2025
7c166fe
clean up commnets
davidkna-sap Dec 17, 2025
714623c
fix ias option forwarding
davidkna-sap Dec 17, 2025
5fdc97e
forward resolved tenantId in serviceToken
davidkna-sap Dec 17, 2025
08313ba
always set token_format: 'jwt'
davidkna-sap Dec 18, 2025
5cda06c
remove iasResource list support & fix tests
davidkna-sap Dec 19, 2025
51129d6
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 19, 2025
67e7d13
fix test again
davidkna-sap Dec 19, 2025
7778cd0
Merge branch 'main' into davidkna-sap_poc-ias
davidkna-sap Dec 19, 2025
41cac51
Changes from lint:fix
Dec 19, 2025
6514dcd
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 22, 2025
87c7a9f
adress review comments
davidkna-sap Dec 22, 2025
ac48d3a
fix `convertResourceToUrn` `providerTenantId` handling
davidkna-sap Dec 22, 2025
8dbc043
extract subdomain from user assertion
davidkna-sap Dec 23, 2025
83ff890
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 29, 2025
0a0d661
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 29, 2025
651882b
Merge branch 'main' into davidkna-sap_poc-ias
KavithaSiva Dec 30, 2025
005e875
address review comments
davidkna-sap Dec 30, 2025
5418c33
update changelog
davidkna-sap Dec 30, 2025
0c67544
address most recent review comments
davidkna-sap Dec 30, 2025
189a529
Apply suggestions from code review
davidkna-sap Jan 5, 2026
74e8bbf
address review comments
davidkna-sap Jan 5, 2026
63d8074
chore: improve `identityServicesCache` comment
davidkna-sap Jan 6, 2026
9fb61ee
chore: add requestAs tests & minor changes
davidkna-sap Jan 6, 2026
60105f1
yarn generate
davidkna-sap Jan 6, 2026
08071e4
Merge branch 'main' into davidkna-sap_poc-ias
davidkna-sap Jan 7, 2026
23a7cad
move requestas to technical user type
davidkna-sap Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/slow-cars-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sap-cloud-sdk/connectivity': minor
---

[New Functionality] Support IAS (App-to-App) authentication (experimental). Use `transformServiceBindingToDestination()` function or `getDestinationFromServiceBinding()` function to create a destination targeting an IAS application.
29 changes: 29 additions & 0 deletions packages/connectivity/src/http-agent/http-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
13 changes: 12 additions & 1 deletion packages/connectivity/src/http-agent/http-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion packages/connectivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ export type {
DestinationJson,
DestinationsByType,
DestinationFromServiceBindingOptions,
ServiceBindingTransformOptions
ServiceBindingTransformOptions,
IasOptions,
IasOptionsBase,
IasOptionsBusinessUser,
IasOptionsTechnicalUser,
IasResource
} from './scp-cf';

export type {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ const ClientCredentialsTokenCache = (
/** *
* @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 - Client ID to fetch the token.
* @returns The cache key.
*/
export function getCacheKey(
tenantId: string | undefined,
Expand All @@ -56,7 +56,8 @@ export function getCacheKey(
);
return;
}
return [tenantId, clientId].join(':');
const parts = [tenantId, clientId];
return parts.join(':');
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -171,6 +172,7 @@ describe('JWT type and selection strategies', () => {
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
identityServicesCache.clear();
});

describe('user token', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -267,6 +268,131 @@ 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, 'getIasClientCredentialsToken')
.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() {
Expand Down Expand Up @@ -391,5 +517,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'
}
}
]
};
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -31,15 +32,25 @@ export async function getDestinationFromServiceBinding(
DestinationFetchOptions,
'jwt' | 'iss' | 'useCache' | 'destinationName'
> &
DestinationFromServiceBindingOptions
DestinationFromServiceBindingOptions & { iasOptions?: IasOptions }
): Promise<Destination> {
const decodedJwt = options.iss
? { iss: options.iss }
: options.jwt
? 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 =
Expand All @@ -60,14 +71,17 @@ async function retrieveDestination({
useCache,
jwt,
destinationName,
iasOptions,
serviceBindingTransformFn
}: Pick<DestinationFetchOptions, 'useCache' | 'destinationName'> & {
jwt?: JwtPayload;
iasOptions?: IasOptions;
} & DestinationFromServiceBindingOptions) {
const service = getServiceBindingByInstanceName(destinationName);
const destination = await (serviceBindingTransformFn || transform)(service, {
useCache,
jwt
jwt,
...(iasOptions ? { iasOptions } : {})
});

return { name: destinationName, ...destination };
Expand All @@ -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;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading