Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
140 changes: 138 additions & 2 deletions packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { signedJwt } from '../../../../../test-resources/test/test-util';
import {
identityServicesCache,
getIdentityServiceInstanceFromCredentials
Expand Down Expand Up @@ -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;
}
60 changes: 40 additions & 20 deletions packages/connectivity/src/scp-cf/environment-accessor/ias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,50 @@ const logger = createLogger({
*/
export const identityServicesCache: Map<string, IdentityService> = 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
Expand All @@ -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 });
Expand Down
74 changes: 74 additions & 0 deletions packages/connectivity/src/scp-cf/identity-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
});
});
});
11 changes: 10 additions & 1 deletion packages/connectivity/src/scp-cf/identity-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,9 +204,18 @@ function transformIasOptionsToXssecArgs(
* @internal
*/
async function getIasTokenImpl(arg: IasParameters): Promise<IasTokenResponse> {
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);
Expand Down
Loading