Skip to content

Commit 8dbc043

Browse files
committed
extract subdomain from user assertion
1 parent ac48d3a commit 8dbc043

File tree

6 files changed

+345
-23
lines changed

6 files changed

+345
-23
lines changed

packages/connectivity/src/scp-cf/environment-accessor/ias.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('ias', () => {
4040
).not.toBe(
4141
getIdentityServiceInstanceFromCredentials(
4242
createServiceCredentials(),
43+
undefined,
4344
true
4445
)
4546
);

packages/connectivity/src/scp-cf/environment-accessor/ias.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
import { IdentityService } from '@sap/xssec';
2-
import type {
3-
ServiceCredentials,
4-
IdentityServiceCredentials
5-
} from './environment-accessor-types';
1+
import { IdentityService, IdentityServiceToken } from '@sap/xssec';
2+
import { ErrorWithCause } from '@sap-cloud-sdk/util';
3+
import type { IdentityServiceCredentials } from './environment-accessor-types';
64

75
const identityServices: Record<string, IdentityService> = {};
86

@@ -15,14 +13,24 @@ export function clearIdentityServices(): void {
1513
Object.keys(identityServices).forEach(key => delete identityServices[key]);
1614
}
1715

16+
function tryParseUrl(url: string, name: string): URL {
17+
try {
18+
return new URL(url);
19+
} catch (err) {
20+
throw new ErrorWithCause(`Could not parse ${name} URL: ${url}`, err);
21+
}
22+
}
23+
1824
/**
1925
* @internal
2026
* @param credentials - Identity service credentials extracted from a service binding or re-use service. Required to create the xssec IdentityService instance.
27+
* @param assertion - Optional JWT assertion to extract the issuer URL for bearer assertion flows.
2128
* @param disableCache - Value to enable or disable JWKS cache in xssec library. Defaults to false.
2229
* @returns An instance of {@code @sap/xssec/IdentityService} for the provided credentials.
2330
*/
2431
export function getIdentityServiceInstanceFromCredentials(
25-
credentials: ServiceCredentials,
32+
credentials: IdentityServiceCredentials,
33+
assertion?: string,
2634
disableCache: boolean = false
2735
): IdentityService {
2836
const serviceConfig = disableCache
@@ -36,11 +44,37 @@ export function getIdentityServiceInstanceFromCredentials(
3644
}
3745
: undefined;
3846

39-
const cacheKey = `${credentials.clientid}:${disableCache}`;
47+
let subdomain: string | undefined;
48+
if (assertion) {
49+
const decodedJwt = new IdentityServiceToken(assertion);
50+
const issuer = decodedJwt.issuer;
51+
const issuerUrl = tryParseUrl(issuer, 'JWT assertion issuer');
52+
subdomain = issuerUrl.hostname.split('.')[0];
53+
// Replace subdomain in the URL from the service binding
54+
// Reason: We don't want to blindly trust the URL in the assertion
55+
const credentialsUrl = tryParseUrl(credentials.url, 'Identity Service');
56+
const credentialsSplit = credentialsUrl.hostname.split('.');
57+
credentialsUrl.hostname = [subdomain, ...credentialsSplit.slice(1)].join(
58+
'.'
59+
);
60+
let normalizedUrl = credentialsUrl.toString();
61+
if (normalizedUrl.endsWith('/')) {
62+
normalizedUrl = normalizedUrl.slice(0, -1);
63+
}
64+
credentials = {
65+
...credentials,
66+
url: normalizedUrl
67+
};
68+
}
69+
70+
subdomain =
71+
subdomain ??
72+
tryParseUrl(credentials.url, 'Identity Service').hostname.split('.')[0];
4073

74+
const cacheKey = `${credentials.clientid}:${subdomain}:${disableCache}`;
4175
if (!identityServices[cacheKey]) {
4276
identityServices[cacheKey] = new IdentityService(
43-
credentials as IdentityServiceCredentials,
77+
credentials,
4478
serviceConfig
4579
);
4680
}

packages/connectivity/src/scp-cf/identity-service.spec.ts

Lines changed: 206 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,32 @@ import type { Service } from './environment-accessor';
99
const mockFetchClientCredentialsToken = jest.fn();
1010
const mockFetchJwtBearerToken = jest.fn();
1111

12-
jest.mock('@sap/xssec', () => ({
13-
...jest.requireActual<object>('@sap/xssec'),
14-
IdentityService: jest.fn().mockImplementation(() => ({
12+
jest.mock('@sap/xssec', () => {
13+
const mockGetSafeUrlFromTokenIssuer = jest.fn();
14+
const mockIdentityService: any = jest.fn().mockImplementation(() => ({
1515
fetchClientCredentialsToken: mockFetchClientCredentialsToken,
1616
fetchJwtBearerToken: mockFetchJwtBearerToken
17-
}))
18-
}));
17+
}));
18+
mockIdentityService.getSafeUrlFromTokenIssuer = mockGetSafeUrlFromTokenIssuer;
19+
20+
return {
21+
...jest.requireActual<object>('@sap/xssec'),
22+
IdentityService: mockIdentityService,
23+
IdentityServiceToken: jest.fn().mockImplementation((jwt: string) => {
24+
const payload = JSON.parse(
25+
Buffer.from(jwt.split('.')[1], 'base64').toString()
26+
);
27+
return {
28+
getPayload: () => payload,
29+
appTid: payload.app_tid ?? payload.zone_uuid,
30+
scimId: payload.scim_id,
31+
consumedApis: payload.ias_apis,
32+
customIssuer: payload.iss,
33+
issuer: payload.iss
34+
};
35+
})
36+
};
37+
});
1938

2039
describe('shouldExchangeToken', () => {
2140
it('should not exchange token from XSUAA', async () => {
@@ -203,7 +222,17 @@ describe('getIasClientCredentialsToken', () => {
203222
);
204223

205224
await expect(getIasClientCredentialsToken(mockIasService)).rejects.toThrow(
206-
'Could not fetch IAS client credentials token for service of type identity'
225+
'Could not fetch IAS client credentials token for service "my-identity-service" of type identity: Network error'
226+
);
227+
});
228+
229+
it('adds multi-tenant hint for 401 errors', async () => {
230+
const error: any = new Error('Unauthorized');
231+
error.response = { status: 401 };
232+
mockFetchClientCredentialsToken.mockRejectedValue(error);
233+
234+
await expect(getIasClientCredentialsToken(mockIasService)).rejects.toThrow(
235+
/ensure that the service instance is declared as dependency to SaaS Provisioning Service or Subscription Manager/
207236
);
208237
});
209238

@@ -232,6 +261,7 @@ describe('getIasClientCredentialsToken', () => {
232261
mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse);
233262

234263
const userAssertion = signedJwt({
264+
iss: 'https://tenant.accounts.ondemand.com',
235265
user_uuid: 'user-123',
236266
app_tid: 'tenant-456'
237267
});
@@ -261,6 +291,7 @@ describe('getIasClientCredentialsToken', () => {
261291
mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse);
262292

263293
const userAssertion = signedJwt({
294+
iss: 'https://tenant.accounts.ondemand.com',
264295
user_uuid: 'user-123',
265296
app_tid: 'tenant-456'
266297
});
@@ -280,4 +311,173 @@ describe('getIasClientCredentialsToken', () => {
280311
});
281312
});
282313
});
314+
315+
describe('multi-tenant subscriber routing', () => {
316+
const providerUrl = 'https://provider.accounts.ondemand.com';
317+
const subscriberUrl = 'https://subscriber.accounts.ondemand.com';
318+
319+
const providerService: Service = {
320+
name: 'provider-ias',
321+
label: 'identity',
322+
tags: ['identity'],
323+
credentials: {
324+
url: providerUrl,
325+
clientid: 'test-client-id',
326+
clientsecret: 'test-secret'
327+
}
328+
};
329+
330+
beforeEach(() => {
331+
jest.clearAllMocks();
332+
clearIdentityServices();
333+
mockFetchJwtBearerToken.mockResolvedValue(mockTokenResponse);
334+
});
335+
336+
it('uses provider IdentityService when JWT issuer matches provider URL', async () => {
337+
const assertion = signedJwt({
338+
iss: providerUrl,
339+
user_uuid: 'user-123'
340+
});
341+
342+
await getIasClientCredentialsToken(providerService, {
343+
authenticationType: 'OAuth2JWTBearer',
344+
assertion
345+
});
346+
347+
expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, {
348+
token_format: 'jwt'
349+
});
350+
});
351+
352+
it('creates subscriber IdentityService when JWT issuer differs from provider', async () => {
353+
const assertion = signedJwt({
354+
iss: subscriberUrl,
355+
user_uuid: 'user-123'
356+
});
357+
358+
const { IdentityService } = jest.requireMock('@sap/xssec');
359+
360+
await getIasClientCredentialsToken(providerService, {
361+
authenticationType: 'OAuth2JWTBearer',
362+
assertion
363+
});
364+
365+
// Verify subscriber instance created with subscriber URL
366+
expect(IdentityService).toHaveBeenCalledWith(
367+
expect.objectContaining({
368+
url: subscriberUrl
369+
}),
370+
undefined
371+
);
372+
373+
expect(mockFetchJwtBearerToken).toHaveBeenCalledWith(assertion, {
374+
token_format: 'jwt'
375+
});
376+
});
377+
378+
it('does not route for client credentials flow', async () => {
379+
const { IdentityService } = jest.requireMock('@sap/xssec');
380+
381+
mockFetchClientCredentialsToken.mockResolvedValue(mockTokenResponse);
382+
383+
await getIasClientCredentialsToken(providerService, {
384+
authenticationType: 'OAuth2ClientCredentials'
385+
});
386+
387+
// Should only create provider instance
388+
expect(IdentityService).toHaveBeenCalledTimes(1);
389+
expect(IdentityService).toHaveBeenCalledWith(
390+
expect.objectContaining({
391+
url: providerUrl
392+
}),
393+
undefined
394+
);
395+
});
396+
397+
it('throws error when JWT issuer extraction fails', async () => {
398+
const assertion = signedJwt({
399+
// Missing issuer field to trigger error
400+
user_uuid: 'user-123'
401+
});
402+
403+
await expect(
404+
getIasClientCredentialsToken(providerService, {
405+
authenticationType: 'OAuth2JWTBearer',
406+
assertion
407+
})
408+
).rejects.toThrow('Could not parse JWT assertion issuer URL: undefined');
409+
});
410+
411+
it('caches subscriber instances per URL', async () => {
412+
const subscriber1Url = 'https://subscriber1.accounts.ondemand.com';
413+
const subscriber2Url = 'https://subscriber2.accounts.ondemand.com';
414+
415+
const assertion1 = signedJwt({
416+
iss: subscriber1Url,
417+
user_uuid: 'user-123'
418+
});
419+
420+
const assertion2 = signedJwt({
421+
iss: subscriber2Url,
422+
user_uuid: 'user-456'
423+
});
424+
425+
const { IdentityService } = jest.requireMock('@sap/xssec');
426+
427+
// First call with subscriber1
428+
await getIasClientCredentialsToken(providerService, {
429+
authenticationType: 'OAuth2JWTBearer',
430+
assertion: assertion1
431+
});
432+
433+
const callsAfterFirst = IdentityService.mock.calls.length;
434+
435+
// Second call with same subscriber1 - should use cached instance
436+
await getIasClientCredentialsToken(providerService, {
437+
authenticationType: 'OAuth2JWTBearer',
438+
assertion: assertion1
439+
});
440+
441+
// Should not create new instance (cached)
442+
expect(IdentityService.mock.calls.length).toBe(callsAfterFirst);
443+
444+
// Third call with different subscriber2 - should create new instance
445+
await getIasClientCredentialsToken(providerService, {
446+
authenticationType: 'OAuth2JWTBearer',
447+
assertion: assertion2
448+
});
449+
450+
// Should create new instance for subscriber2
451+
expect(IdentityService.mock.calls.length).toBeGreaterThan(
452+
callsAfterFirst
453+
);
454+
});
455+
456+
it('handles URLs with trailing slashes correctly', async () => {
457+
const providerWithSlash = 'https://provider.accounts.ondemand.com/';
458+
459+
const serviceWithSlash: Service = {
460+
...providerService,
461+
credentials: {
462+
...providerService.credentials,
463+
url: providerWithSlash
464+
}
465+
};
466+
467+
const assertion = signedJwt({
468+
iss: 'https://provider.accounts.ondemand.com',
469+
user_uuid: 'user-123'
470+
});
471+
472+
const { IdentityService } = jest.requireMock('@sap/xssec');
473+
474+
await getIasClientCredentialsToken(serviceWithSlash, {
475+
authenticationType: 'OAuth2JWTBearer',
476+
assertion
477+
});
478+
479+
// Should handle URLs with and without trailing slashes
480+
expect(IdentityService).toHaveBeenCalled();
481+
});
482+
});
283483
});

packages/connectivity/src/scp-cf/identity-service.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { executeWithMiddleware } from '@sap-cloud-sdk/resilience/internal';
22
import { resilience } from '@sap-cloud-sdk/resilience';
33
import { IdentityServiceToken, type IdentityService } from '@sap/xssec';
4+
import { ErrorWithCause } from '@sap-cloud-sdk/util';
45
import { decodeJwt, isXsuaaToken } from './jwt';
56
import {
67
resolveServiceBinding,
@@ -99,8 +100,19 @@ export async function getIasClientCredentialsToken(
99100
tenantId: fnArgument.serviceCredentials.tenantid
100101
}
101102
}).catch(err => {
102-
throw new Error(
103-
`Could not fetch IAS client credentials token for service of type ${resolvedService.label}: ${err.message}`
103+
const serviceName =
104+
typeof service === 'string' ? service : service.name || 'unknown';
105+
let message = `Could not fetch IAS client credentials token for service "${serviceName}" of type ${resolvedService.label}`;
106+
107+
// Add contextual hints based on error status code (similar to Java SDK)
108+
if (err.response?.status === 401) {
109+
message +=
110+
'. In case you are accessing a multi-tenant BTP service on behalf of a subscriber tenant, ensure that the service instance is declared as dependency to SaaS Provisioning Service or Subscription Manager (SMS) and subscribed for the current tenant';
111+
}
112+
113+
throw new ErrorWithCause(
114+
message + (err.message ? `: ${err.message}` : '.'),
115+
err
104116
);
105117
});
106118
return token;
@@ -138,7 +150,8 @@ async function getIasClientCredentialsTokenImpl(
138150
arg: IasParameters
139151
): Promise<IasClientCredentialsResponse> {
140152
const identityService = getIdentityServiceInstanceFromCredentials(
141-
arg.serviceCredentials
153+
arg.serviceCredentials,
154+
arg.assertion
142155
);
143156

144157
const authenticationType =

0 commit comments

Comments
 (0)