From b03753363e48bfdc9a8218234d7dec5b6d9ac942 Mon Sep 17 00:00:00 2001 From: yogeshwaran-c Date: Thu, 16 Apr 2026 07:54:39 +0530 Subject: [PATCH 1/4] fix(auth): use Immutable Map as fallback in logout cookie deletion When logging out, the auth wrap-action used a plain JS object {} as the default value for authorized.get(), but then called .getIn() on it which is an Immutable.js method not available on plain objects. This caused a TypeError: o.getIn is not a function when the authorized entry was not found. Changed the fallback to Map() from Immutable.js so that getIn() calls work correctly. Fixes #10761 --- src/core/plugins/auth/wrap-actions.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/plugins/auth/wrap-actions.js b/src/core/plugins/auth/wrap-actions.js index b7f12b342dc..fa2792b2ee8 100644 --- a/src/core/plugins/auth/wrap-actions.js +++ b/src/core/plugins/auth/wrap-actions.js @@ -1,7 +1,7 @@ /** * @prettier */ -import { fromJS } from "immutable" +import { fromJS, Map } from "immutable" /** * `authorize` and `logout` wrapped actions provide capacity @@ -44,7 +44,7 @@ export const logout = (oriAction, system) => (payload) => { try { if (configs.persistAuthorization && Array.isArray(payload)) { payload.forEach((authorizedName) => { - const auth = authorized.get(authorizedName, {}) + const auth = authorized.get(authorizedName, Map()) const isApiKeyAuth = auth.getIn(["schema", "type"]) === "apiKey" const isInCookie = auth.getIn(["schema", "in"]) === "cookie" const isApiKeyInCookie = isApiKeyAuth && isInCookie From c0c74c0cac59102d11b95c789b7aaab6fc61fe3d Mon Sep 17 00:00:00 2001 From: yogeshwaran-c Date: Mon, 20 Apr 2026 14:33:09 +0530 Subject: [PATCH 2/4] test(auth): cover logout when payload contains unauthorized names Adds unit tests that exercise the logout cookie cleanup path when the payload from the Authorize popup contains security scheme names that are absent from the authorized store. This is the scenario that originally triggered issue #10761: `authorized.get(name)` returned the fallback, and the subsequent `.getIn(...)` call crashed. The new tests lock in the fallback behavior so the cookie cleanup stays resilient. --- test/unit/core/plugins/auth/wrap-actions.js | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/unit/core/plugins/auth/wrap-actions.js b/test/unit/core/plugins/auth/wrap-actions.js index f9f3b98575c..9fb62eeb0f1 100644 --- a/test/unit/core/plugins/auth/wrap-actions.js +++ b/test/unit/core/plugins/auth/wrap-actions.js @@ -90,6 +90,58 @@ describe("Cookie based apiKey persistence in document.cookie", () => { expect(document.cookie).toEqual("apiKeyCookie=; Max-Age=-99999999") }) + + it("should delete cookie even when payload contains names missing from the authorized store", () => { + // The Authorize popup's Logout button sends every security + // definition name in the displayed group, including ones the user + // never authorized. Those names are absent from the `authorized` + // store, so `authorized.get(name)` falls back to a default value. + // This test guards the fix for + // https://github.com/swagger-api/swagger-ui/issues/10761 where the + // previous plain-object fallback caused a crash on `.getIn(...)`. + const authorized = fromJS({ + api_key: { + schema: { + type: "apiKey", + name: "apiKeyCookie", + in: "cookie", + }, + value: "test", + }, + }) + const system = { + getConfigs: () => ({ + persistAuthorization: true, + }), + authSelectors: { + authorized: () => authorized, + }, + } + const oriAction = jest.fn() + + const logoutAction = () => + logout(oriAction, system)(["missing_auth", "api_key"]) + + expect(logoutAction).not.toThrow() + expect(document.cookie).toEqual("apiKeyCookie=; Max-Age=-99999999") + expect(oriAction).toHaveBeenCalledWith(["missing_auth", "api_key"]) + }) + + it("should not throw when every name in payload is missing from the authorized store", () => { + const system = { + getConfigs: () => ({ + persistAuthorization: true, + }), + authSelectors: { + authorized: () => fromJS({}), + }, + } + + const logoutAction = () => logout(jest.fn(), system)(["missing_auth"]) + + expect(logoutAction).not.toThrow() + expect(document.cookie).toEqual("") + }) }) describe("given persistAuthorization=false", () => { From 76325c779a91b3804d22a8c883a0e4fb72b599a1 Mon Sep 17 00:00:00 2001 From: yogeshwaran-c Date: Mon, 20 Apr 2026 14:36:26 +0530 Subject: [PATCH 3/4] docs(auth): explain why logout payload may reference missing entries The Authorize popup's Logout button submits every security definition in the displayed group, even ones the user never authorized. Those entries are absent from the authorized store, so the fallback Map() default is exercised on purpose. Document this so future readers don't mistake the fallback for a safety net around an unknown bug. --- src/core/plugins/auth/wrap-actions.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/core/plugins/auth/wrap-actions.js b/src/core/plugins/auth/wrap-actions.js index fa2792b2ee8..97524d1a72f 100644 --- a/src/core/plugins/auth/wrap-actions.js +++ b/src/core/plugins/auth/wrap-actions.js @@ -44,6 +44,13 @@ export const logout = (oriAction, system) => (payload) => { try { if (configs.persistAuthorization && Array.isArray(payload)) { payload.forEach((authorizedName) => { + // The Authorize popup's Logout button submits every security + // definition in the displayed group, regardless of whether the + // user actually authorized it. Entries the user never filled in + // are not present in the `authorized` store, so we fall back to + // an empty Immutable Map() to keep the subsequent getIn() calls + // safe. Such entries have nothing stored in document.cookie, so + // skipping them is the correct behavior. const auth = authorized.get(authorizedName, Map()) const isApiKeyAuth = auth.getIn(["schema", "type"]) === "apiKey" const isInCookie = auth.getIn(["schema", "in"]) === "cookie" From 39bcab92e41e46e4879622880a6199e6b3a1add8 Mon Sep 17 00:00:00 2001 From: yogeshwaran-c Date: Tue, 21 Apr 2026 11:17:09 +0530 Subject: [PATCH 4/4] fix(auth): emit scheme-name keys from logoutClick under Immutable v4 Immutable v4 changed `Map#toArray()` to emit `[key, value]` pairs rather than values-only. The existing `definitions.map((val, key) => key).toArray()` pattern therefore produced `[[name, schema]]` instead of `[name]` when a host bundle ships Immutable v4+ alongside swagger-ui (e.g. a Vite/Rolldown build in a user app). That malformed payload: - made the LOGOUT reducer a no-op for the real scheme, so apiKey cookies were never cleared, and - made `authorized.get(payload[0])` miss, triggering `.getIn` on the default fallback and crashing logout (#10761). Use `keySeq().toArray()` instead, which returns the flat key array under both v3 and v4. The defensive `Map()` fallback in `wrap-actions.js` stays for any other upstream callers that pass an unexpected key shape. --- src/core/components/auth/auths.jsx | 10 ++- src/core/plugins/auth/wrap-actions.js | 12 ++- .../plugins/oas31/components/auth/auths.jsx | 12 +-- test/unit/core/components/auth/auths.jsx | 85 +++++++++++++++++++ test/unit/core/plugins/auth/wrap-actions.js | 13 ++- 5 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 test/unit/core/components/auth/auths.jsx diff --git a/src/core/components/auth/auths.jsx b/src/core/components/auth/auths.jsx index a29b57359f3..b5b97787931 100644 --- a/src/core/components/auth/auths.jsx +++ b/src/core/components/auth/auths.jsx @@ -35,9 +35,13 @@ export default class Auths extends React.Component { e.preventDefault() let { authActions, definitions } = this.props - let auths = definitions.map( (val, key) => { - return key - }).toArray() + // `definitions` is an Immutable.Map whose keys are the security scheme + // names we want to log out of. Use `keySeq().toArray()` rather than + // `.map((val, key) => key).toArray()` because `Map#toArray()` returns + // `[key, value]` pairs under Immutable v4+ and values-only under v3, + // so the latter pattern silently produces `[[name, schema]]` instead + // of `[name]` when the host bundles a newer Immutable (see #10761). + let auths = definitions.keySeq().toArray() this.setState(auths.reduce((prev, auth) => { prev[auth] = "" diff --git a/src/core/plugins/auth/wrap-actions.js b/src/core/plugins/auth/wrap-actions.js index 97524d1a72f..ff478c067b6 100644 --- a/src/core/plugins/auth/wrap-actions.js +++ b/src/core/plugins/auth/wrap-actions.js @@ -44,13 +44,11 @@ export const logout = (oriAction, system) => (payload) => { try { if (configs.persistAuthorization && Array.isArray(payload)) { payload.forEach((authorizedName) => { - // The Authorize popup's Logout button submits every security - // definition in the displayed group, regardless of whether the - // user actually authorized it. Entries the user never filled in - // are not present in the `authorized` store, so we fall back to - // an empty Immutable Map() to keep the subsequent getIn() calls - // safe. Such entries have nothing stored in document.cookie, so - // skipping them is the correct behavior. + // Defend against missing entries in the `authorized` store — e.g. + // when a name was never authorized, or when an upstream caller + // passes an unexpected key shape. Use an empty Immutable Map() so + // the following getIn() calls are always safe; plain-object + // fallbacks crash here because getIn is Immutable-specific. const auth = authorized.get(authorizedName, Map()) const isApiKeyAuth = auth.getIn(["schema", "type"]) === "apiKey" const isInCookie = auth.getIn(["schema", "in"]) === "cookie" diff --git a/src/core/plugins/oas31/components/auth/auths.jsx b/src/core/plugins/oas31/components/auth/auths.jsx index 227d5656c46..da4e6775838 100644 --- a/src/core/plugins/oas31/components/auth/auths.jsx +++ b/src/core/plugins/oas31/components/auth/auths.jsx @@ -38,11 +38,13 @@ class Auths extends React.Component { e.preventDefault() let { authActions, definitions } = this.props - let auths = definitions - .map((val, key) => { - return key - }) - .toArray() + // `definitions` is an Immutable.Map whose keys are the security scheme + // names we want to log out of. Use `keySeq().toArray()` rather than + // `.map((val, key) => key).toArray()` because `Map#toArray()` returns + // `[key, value]` pairs under Immutable v4+ and values-only under v3, + // so the latter pattern silently produces `[[name, schema]]` instead + // of `[name]` when the host bundles a newer Immutable (see #10761). + let auths = definitions.keySeq().toArray() this.setState( auths.reduce((prev, auth) => { diff --git a/test/unit/core/components/auth/auths.jsx b/test/unit/core/components/auth/auths.jsx new file mode 100644 index 00000000000..7dc0d0d17c3 --- /dev/null +++ b/test/unit/core/components/auth/auths.jsx @@ -0,0 +1,85 @@ +/** + * @prettier + */ +import React from "react" +import { shallow } from "enzyme" +import { fromJS, Map } from "immutable" + +import Auths from "core/components/auth/auths" + +describe(" logoutClick", function () { + const dummyComponent = () => null + const components = { + AuthItem: dummyComponent, + oauth2: dummyComponent, + Button: dummyComponent, + } + const getComponentStub = (name) => components[name] || dummyComponent + + const buildProps = (definitions) => ({ + definitions, + getComponent: getComponentStub, + authSelectors: { + authorized: () => Map(), + }, + authActions: { + logoutWithPersistOption: jest.fn(), + }, + errSelectors: {}, + specSelectors: {}, + }) + + it("sends only the scheme names to logoutWithPersistOption for a single-scheme map", function () { + // The Authorize popup renders one per requirement group, + // passing an Immutable.Map whose keys are the scheme names. The + // Logout handler must emit those keys as a flat array of strings. + const definitions = fromJS({ + ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, + }) + const props = buildProps(definitions) + const wrapper = shallow() + + wrapper.instance().logoutClick({ preventDefault: () => {} }) + + expect(props.authActions.logoutWithPersistOption).toHaveBeenCalledWith([ + "ApiKeyAuth", + ]) + }) + + it("sends all scheme names when the map has several entries", function () { + const definitions = fromJS({ + ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, + BearerAuth: { type: "http", scheme: "bearer" }, + }) + const props = buildProps(definitions) + const wrapper = shallow() + + wrapper.instance().logoutClick({ preventDefault: () => {} }) + + expect(props.authActions.logoutWithPersistOption).toHaveBeenCalledWith([ + "ApiKeyAuth", + "BearerAuth", + ]) + }) + + it("does not leak [key, value] pairs into the payload (guard against Immutable v4 toArray semantics)", function () { + // `Map#toArray()` switched in Immutable v4 from values-only to + // `[key, value]` pairs. The previous `.map((v, k) => k).toArray()` + // pattern therefore emitted `[[name, schema]]` when a host bundled + // Immutable v4+, which broke logout (see #10761). Guard the flat- + // string shape regardless of Immutable version. + const definitions = fromJS({ + ApiKeyAuth: { type: "apiKey", in: "header", name: "X-API-Key" }, + }) + const props = buildProps(definitions) + const wrapper = shallow() + + wrapper.instance().logoutClick({ preventDefault: () => {} }) + + const [payload] = props.authActions.logoutWithPersistOption.mock.calls[0] + expect(Array.isArray(payload)).toBe(true) + expect(payload).toHaveLength(1) + expect(typeof payload[0]).toBe("string") + expect(payload[0]).toBe("ApiKeyAuth") + }) +}) diff --git a/test/unit/core/plugins/auth/wrap-actions.js b/test/unit/core/plugins/auth/wrap-actions.js index 9fb62eeb0f1..38f90f27ea5 100644 --- a/test/unit/core/plugins/auth/wrap-actions.js +++ b/test/unit/core/plugins/auth/wrap-actions.js @@ -92,13 +92,12 @@ describe("Cookie based apiKey persistence in document.cookie", () => { }) it("should delete cookie even when payload contains names missing from the authorized store", () => { - // The Authorize popup's Logout button sends every security - // definition name in the displayed group, including ones the user - // never authorized. Those names are absent from the `authorized` - // store, so `authorized.get(name)` falls back to a default value. - // This test guards the fix for - // https://github.com/swagger-api/swagger-ui/issues/10761 where the - // previous plain-object fallback caused a crash on `.getIn(...)`. + // Defensive: if `authorized.get(name)` can't find an entry, the + // fallback must expose `.getIn(...)` safely. A plain-object + // fallback crashes here; an empty Immutable Map() keeps the + // iteration going and still clears cookies for names that are + // present. Guards the fix for + // https://github.com/swagger-api/swagger-ui/issues/10761. const authorized = fromJS({ api_key: { schema: {