Skip to content

B2B organization switch fails when the Console is served on a hostname different from server.hostname (unexpected "iss" claim value) #28211

Description

@vfraga

Description

When the React Console is served under a hostname different from the Identity Server's [server] hostname — the documented “separate hostname via reverse proxy” approach — the in-Console organization switch fails. The UI shows “Something went wrong — Couldn't switch to the selected organization”, or renders nothing at all, because the error is swallowed by an empty catch in the switch handlers.

Normal sign-in and the root Console work correctly with the same configuration. Only the organization switch fails.

The grant succeeds; the SDK rejects the result.

The organization_switch grant returns HTTP 200 with a valid id_token whose iss is stamped from [server] hostname:

POST https://is-console.local/oauth2/token      (grant_type=organization_switch)
→ 200 OK, id_token.iss = https://is-runtime.local/oauth2/token
GET  https://is-console.local/oauth2/jwks       → 200 OK

The bundled @asgardeo/auth-spa SDK then throws client-side:

unexpected "iss" claim value

(captured via CDP Debugger.setPauseOnExceptions on the Console's web worker, where the token calls actually run — page-level listeners do not see them).

Why the issuer differs between sign-in and organization switch

At initial sign-in the SDK is configured with the issuer taken explicitly from idpConfigs.issuer, which honours [console.idp_configs] issuer:

// features/admin.authentication.v1/utils/authenticate-utils.ts
endpoints: {
    authorizationEndpoint: window["AppUtils"]?.getConfig()?.idpConfigs?.authorizeEndpointURL,
    issuer: window["AppUtils"]?.getConfig()?.idpConfigs?.issuer,      // ← honoured here
    tokenEndpoint: window["AppUtils"]?.getConfig()?.idpConfigs?.tokenEndpointURL,
    ...
}

After sign-in, the endpoint configuration is re-derived — but only the authorize, session-iframe and token endpoints are carried over. issuer is not among them (the identifier does not appear anywhere in this file):

// features/admin.authentication.v1/hooks/use-sign-in.ts
getOIDCServiceEndpoints().then((response: OIDCEndpoints) => {
    let authorizationEndpoint = response.authorizationEndpoint;
    let oidcSessionIframeEndpoint = response.checkSessionIframe;
    let tokenEndpoint = response.tokenEndpoint;

    if (window["AppUtils"].getConfig().idpConfigs?.authorizeEndpointURL) { authorizationEndpoint = ... }
    if (window["AppUtils"].getConfig().idpConfigs?.oidcSessionIFrameEndpointURL) { oidcSessionIframeEndpoint = ... }
    if (window["AppUtils"].getConfig().idpConfigs?.tokenEndpointURL) { tokenEndpoint = ... }
    // no equivalent handling for `issuer`

The endpoint URLs themselves are built by substituting the ${serverOrigin} placeholder, which resolves to console.server_origin — i.e. the Console hostname in a split setup:

// apps/console/src/init/app-utils.ts
const getReplaceServerOrigin = () => {
    return _config.serverOrigin + this.getTenantPath(!_config.tenantContext?.requireSuperTenantInUrls);
};

So the organization-switch token validation ends up comparing iss against an issuer derived from the Console origin, while the server stamps iss from [server] hostname (the runtime origin). In a single-host deployment these are the same string and the switch works; in a split-hostname deployment they differ and every switch is rejected.

This is not proxy-specific. Reproduced identically with an NGINX reverse proxy (sub_filter + proxy_redirect) and with an Envoy Gateway EnvoyExtensionPolicy Lua filter performing the equivalent rewrite.

Steps to Reproduce

Two hostnames are used below: is-runtime.local is the Identity Server's own [server] hostname (the OAuth issuer host), and is-console.local is the separate hostname the Console is served on. Point both at the IS host (e.g. via /etc/hosts).

  1. Configure <IS_HOME>/repository/conf/deployment.toml per the documented separate-Console-hostname approach and restart:

    [server]
    hostname = "is-runtime.local"
    base_path = "https://$ref{server.hostname}"
    
    [transport.https.properties]
    proxyPort = 443
    
    [cors]
    allowed_origins = ["https://is-console.local"]
    
    [console]
    callback_url = "regexp=(https://is-console.local/console|https://is-console.local/t/carbon.super/console|https://is-console.local/t/carbon.super/o/(.*)/console)"
    server_origin = "https://is-console.local"
    
    [console.idp_configs]
    signInRedirectURL = "https://is-console.local/console"
    issuer = "https://is-runtime.local/oauth2/token"
  2. Put a reverse proxy in front that keeps the browser on is-console.local and rewrites the backend host in bodies and redirects:

    upstream is_backend { server 127.0.0.1:9443; ip_hash; }
    
    server {                        # runtime host — full IS
        listen 443 ssl;
        server_name is-runtime.local;
        location / {
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Proto https;
            proxy_pass https://is_backend;
            proxy_cookie_path / /;
        }
    }
    
    server {                        # console host — console + auth endpoints, with host rewrite
        listen 443 ssl;
        server_name is-console.local;
    
        location ~ ^(/t/[^/]+)?(/o(/[^/]+)?)?/(api|scim2|logincontext)(.*)$ {
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Proto https;
            proxy_pass https://is_backend;
            proxy_cookie_path / /;
        }
        location ~ ^(/t/[^/]+)?(/o/[^/]+)?/(console|oauth2|oidc|authenticationendpoint|accountrecoveryendpoint|commonauth)(.*)$ {
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Proto https;
            proxy_set_header Accept-Encoding "";
            sub_filter 'is-runtime.local' 'is-console.local';
            sub_filter_once off;
            proxy_redirect https://is-runtime.local/ https://is-console.local/;
            proxy_pass https://is_backend;
            proxy_cookie_path / /;
        }
    }
  3. Start the server and create a sub-organization (e.g. testorg).

  4. Open https://is-console.local/console and sign in as the root administrator. The root Console loads and works — sign-in is unaffected.

  5. Go to Organizations, open the sub-organization, and click Switch to Organization (or the switch icon in the list).

  6. Observe: the switch fails. Devtools shows POST /oauth2/token (grant_type=organization_switch) returning 200 with a valid id_token, followed by the client-side exception unexpected "iss" claim value. The error surfaces as a “Something went wrong” toast, or silently, depending on the entry point.

Control (baseline): with a standard single-host Console — [server] hostname equal to the host the browser uses, no proxy or rewriting — the identical switch succeeds and lands in the sub-organization Console. The failure is specific to the split-hostname configuration.

Expected Behavior

With the Console served on a separate hostname as documented, the organization switch should succeed. The issuer used to validate the organization_switch token should be consistent with the one used at initial sign-in — i.e. it should honour the configured [console.idp_configs] issuer rather than being derived from console.server_origin.

Alternatively, if organization switching is not intended to be supported alongside a separate Console hostname, that limitation should be stated explicitly in the “Restrict public access to management operations” documentation, since the two features are currently documented independently and appear compatible.

Proposed Fix

Carry the configured issuer through the post-sign-in endpoint refresh, so that later token acquisitions (including organization_switch) validate against the same issuer as the initial sign-in. In use-sign-in.ts, issuer is handled for none of the cases that authorizeEndpointURL, oidcSessionIFrameEndpointURL and tokenEndpointURL are handled for; adding the equivalent idpConfigs.issuer handling there (and persisting it alongside the other endpoints) would keep the two paths consistent.

This has not been validated as a patch — it is the direction suggested by the asymmetry between authenticate-utils.ts and use-sign-in.ts.

Please select the area issue is related to

Other

Version

IS 7.1.0, 7.2.0, 7.3.0

Environment Details (with versions)

Reproduced on WSO2 Identity Server 7.1.0 (latest update level) on a single node, with the Console fronted by (a) NGINX using sub_filter + proxy_redirect, and (b) Envoy Gateway using an EnvoyExtensionPolicy Lua filter performing the equivalent host rewrite. Both fail identically.

Developer Checklist

  • [Behavioural Change] Does this change introduce a behavioral change to the product?
  •  ↳ Approved by team lead
  •  ↳ Label impact/behavioral-change added
  • [Migration Impact] Does this change have a migration impact?
  •  ↳ Migration label added (e.g., 7.2.0-migration)
  •  ↳ Migration issues created and linked
  • [New Configuration] Does this change introduce a new configuration?
  •  ↳ Label config added
  •  ↳ Configuration is properly documented

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions