Skip to content

fix(connectivity): Handle schemeless issuer URLs in JWT claims - #6948

Open
IncognitoQuack wants to merge 2 commits into
SAP:mainfrom
IncognitoQuack:fix/schemeless-issuer-url
Open

fix(connectivity): Handle schemeless issuer URLs in JWT claims#6948
IncognitoQuack wants to merge 2 commits into
SAP:mainfrom
IncognitoQuack:fix/schemeless-issuer-url

Conversation

@IncognitoQuack

@IncognitoQuack IncognitoQuack commented Sep 5, 2026

Copy link
Copy Markdown

Closes #6923.

An identity provider can be configured not to force https on the issuer URL, in which case the
iss (or ias_iss) claim arrives as a bare hostname such as tenant.accounts.ondemand.com. The
URL constructor rejects that, and two places in the connectivity package assume it will not.

1. getIssuerSubdomain throws where its caller expects a falsy return

getIdentityServiceInstanceFromCredentialsextractSubdomainFromJwt calls
getIssuerSubdomain(payload, true) and then does:

const subdomain = getIssuerSubdomain(payload, true);
if (!subdomain) {
  logger.warn('Could not extract subdomain from JWT assertion issuer. Falling back to service binding URL.');
}

The caller is written for a falsy return, but getIssuerSubdomain throws when isValidUrl(issuer)
is false, so the intended fallback never runs. The throw propagates to retrieveServiceToken, which
catches it and only logs a warning, so serviceJwt stays undefined, isSubscriberNeeded()
returns false, and getDestination({ destinationName, jwt }) silently reads the destination from
the provider account instead of the subscriber account — the symptom reported in #6923.

2. isIasToken misclassifies the same tokens

isIasToken parses decodedJwt.iss with new URL(...) inside a try whose catch returns
false, so a schemeless IAS issuer is classified as not an IAS token. getSubdomain then takes
the XSUAA branch and calls getIssuerSubdomain — which, before this change, throws, and with only
the first fix in place returns a subdomain that is then sent as the X-tenant header in
getExchangeTenant. Fixing only the first function would turn a loud failure into a silently wrong
tenant header, so both are fixed here.

Change

Schemeless issuers are prefixed with https:// before being parsed. Values that are not URLs at all
('not a url', '') are still rejected, and existing http:// and https:// issuers, issuers
with paths, and issuers with explicit ports are unaffected.

Note that URL.canParse alone is not sufficient: it returns true for
tenant.accounts.ondemand.com:8443, which parses as a scheme with no host, so the prefix is applied
whenever parsing does not yield a host. There are regression tests for that case.

Behaviour changes

  • iss: 'localhost' now throws Failed to determine hostname: invalid host in "https://localhost/"
    instead of Issuer URL in JWT is not a valid URL: "localhost".
  • iss: 'https://' now throws Failed to determine hostname instead of is not a valid URL.

Both cases still throw; only the messages differ.

Tests

subdomain-replacer.ts had no test coverage before this change. This PR adds
subdomain-replacer.spec.ts covering both getIssuerSubdomain and replaceSubdomain, including
the pre-existing behaviour, and extends jwt.spec.ts with describe('isIasToken()') and
describe('getSubdomain()') blocks.

@davidkna-sap davidkna-sap changed the title Fix/schemeless issuer url fix(connectivity): Handle scheme less issuer URLs in JWT claims. Sep 8, 2026
@davidkna-sap davidkna-sap changed the title fix(connectivity): Handle scheme less issuer URLs in JWT claims. fix(connectivity): Handle schemeless issuer URLs in JWT claims Sep 8, 2026

@davidkna-sap davidkna-sap left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For parsing the URL, please switch to a simpler approach that avoids regular expressions e.g:

URL.canParse(issuer)
    ? new URL(issuer)
    : new URL(`https://${issuer}`);

@IncognitoQuack
IncognitoQuack force-pushed the fix/schemeless-issuer-url branch from 5973bac to 20ff422 Compare September 8, 2026 14:41
@IncognitoQuack

Copy link
Copy Markdown
Author

Thanks — switched to URL.canParse, the regex is gone from both subdomain-replacer.ts and
jwt.ts, and I've rebased onto latest main.

One thing worth flagging before you re-review: the literal form has an edge case I hit while
testing it.

URL.canParse('tenant.accounts.ondemand.com:8443'); // true

It returns true because the value parses as a scheme (tenant.accounts.ondemand.com:) with a path
and no host — so the https:// prefix is not applied, getHost finds an empty host, and a
schemeless issuer with a port throws instead of resolving. That is the same class of value this PR
is meant to fix, so I didn't want to leave it broken.

The version I pushed keeps your approach and adds a host check:

const parsedIssuer = URL.canParse(issuer) ? new URL(issuer) : undefined;
const normalizedIssuer = parsedIssuer?.host ? issuer : `https://${issuer}`;

I added two regression tests for it (getIssuerSubdomain and isIasToken); both fail with the
plain URL.canParse(issuer) ? … : … form and pass with the above.

Happy to drop back to your exact snippet if you'd rather not carry the extra check — it's your call
on whether a ported issuer is worth covering.

One disclosed behaviour change either way: iss: "https://" now throws
Failed to determine hostname rather than is not a valid URL. Both still reject the value; only
the message differs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cant read subscriber destination

2 participants