Skip to content

fix(auth): use Immutable Map as fallback in logout cookie deletion#10839

Open
yogeshwaran-c wants to merge 4 commits intoswagger-api:masterfrom
yogeshwaran-c:fix/auth-logout-cookie-getIn-crash
Open

fix(auth): use Immutable Map as fallback in logout cookie deletion#10839
yogeshwaran-c wants to merge 4 commits intoswagger-api:masterfrom
yogeshwaran-c:fix/auth-logout-cookie-getIn-crash

Conversation

@yogeshwaran-c
Copy link
Copy Markdown

Description

When logging out of an API key authorization, the auth logout wrap-action in src/core/plugins/auth/wrap-actions.js used a plain JavaScript object {} as the default value for authorized.get(authorizedName, {}). However, subsequent code calls .getIn() on this fallback value, which is an Immutable.js method not available on plain objects.

This caused the error:

TypeError: o.getIn is not a function

The fix changes the fallback from {} to Map() (from Immutable.js), ensuring that .getIn() calls work correctly even when the authorization entry is not found in the store.

Motivation and Context

Fixes #10761

The crash occurs when clicking the Logout button for an ApiKeyAuth in the Available authorizations popup, specifically when the authorized entry cannot be found in the Immutable store.

How Has This Been Tested?

  • Verified that Map() from Immutable.js has the .getIn() method
  • Map().getIn(["schema", "type"]) returns undefined rather than throwing, which correctly prevents the cookie deletion logic from executing
  • The authorize action in the same file already uses fromJS() to convert to Immutable, confirming that Immutable types are the expected data type

Screenshots (if appropriate):

N/A

Checklist

My PR contains...

  • No code changes (src/ is unmodified: changes to documentation, CI, metadata, etc.)
  • Dependency changes (any modification to dependencies in package.json)
  • Bug fixes (non-breaking change which fixes an issue)
  • Improvements (misc. changes to existing features)
  • Features (non-breaking change which adds functionality)

My changes...

  • are breaking changes to a public API (config options, System API, major UI change, etc).
  • are breaking changes to a private API (Redux, component props, utility functions, etc.).
  • are breaking changes to a developer API (npm script behavior changes, new dev system dependencies, etc).
  • are not breaking changes.

Documentation

  • My changes do not require a change to the project documentation.
  • My changes require a change to the project documentation.
  • If yes to above: I have updated the documentation accordingly.

Automated tests

  • My changes can not or do not need to be tested.
  • My changes can and should be tested by unit and/or integration tests.
  • If yes to above: I have added tests to cover my changes.
  • If yes to above: I have taken care to cover edge cases in my tests.
  • All new and existing tests passed.

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 swagger-api#10761
@lukaszzazulak
Copy link
Copy Markdown
Contributor

lukaszzazulak commented Apr 17, 2026

@yogeshwaran-c I am wondering why the authorization entry is not found in the store in this case - if it is supposed to be found but isn't, the apikey won't be removed from the cookies, thus the problem will still be there.

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 swagger-api#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.
@yogeshwaran-c
Copy link
Copy Markdown
Author

@lukaszzazulak Thanks for pushing on the root cause — you're right that if an apikey we expect to delete is missing from authorized, just silencing the crash wouldn't help.

I dug into why authorized.get(authorizedName) returns nothing here, and it turns out the missing entry is expected in this code path, not a sign of a lost authorization. The Authorize popup's Logout button (src/core/components/auth/auths.jsx, logoutClick) builds its payload from every security definition, not just the ones the user actually authorized:

let auths = definitions.map((val, key) => key).toArray()
authActions.logoutWithPersistOption(auths)

And the Logout button visibility check (nonOauthDefinitions.size === authorizedAuth.size) compares against authorizedAuth, which is definitions.filter(...) across all types (including oauth2 and, on OAS 3.1, mutualTLS). So with a spec like {ApiKey, OAuth2} or {ApiKey, MutualTLS} where the user only authorizes one scheme, the Logout button can appear and fire, and the payload then contains names like OAuth2 / MutualTLS that were never added to authorized.

The LOGOUT reducer already tolerates this — Map#delete on a missing key is a no-op — but the cookie-cleanup in wrap-actions.js (which was bolted on later for persistAuthorization) didn't tolerate it, so it crashed on the first unauthorized name in the payload. The reproduction in the original issue is on a spec with multiple auth schemes, which matches this pattern.

Given that, the Map() fallback is the semantically right fix for wrap-actions.js: .getIn(["schema", "type"]) on an empty Map returns undefined, isApiKeyInCookie stays false, and we correctly skip names that have no cookie to clean up. I've pushed c0c74c0 adding unit tests that exercise exactly this path — mixed payload (one authorized, one missing) and an all-missing payload — so future changes don't regress it.

The over-broad logout payload from logoutClick itself is arguably a separate issue: iterating all definition keys does extra work and means persistAuthorizationIfNeeded writes an identical authorized back to localStorage for schemes that weren't touched. It also affects the legacy OAS 2/3 auths.jsx and the OAS 3.1 auths.jsx the same way. Happy to open a follow-up PR narrowing that payload to only the currently-authorized keys if you'd like — I kept it out of this PR to keep the scope to the crash fix the issue reported.

Tests pass locally (npm run test:unit -- test/unit/core/plugins/auth/wrap-actions.js: 7/7).

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.
@yogeshwaran-c
Copy link
Copy Markdown
Author

@lukaszzazulak Good question — I dug into it. The entry genuinely isn't in the store for those names, and here's why.

Why the authorization entry can be missing

The payload here comes from logoutClick in src/core/components/auth/auths.jsx:

logoutClick =(e) => {
  let { authActions, definitions } = this.props
  let auths = definitions.map((val, key) => key).toArray()
  ...
  authActions.logoutWithPersistOption(auths)
}

It iterates over every security definition in the displayed group, regardless of whether the user actually filled it in. So when a spec has more than one security scheme in the same group (or any unauthorized scheme is shown alongside an authorized apiKey), the payload includes names that were never authorized and therefore never written to authSelectors.authorized(). For those names, authorized.get(name) returns the default fallback — nothing to find in the store because nothing was ever stored.

Does the apiKey still get removed?

Yes, and the new test locks this down. I reproduced the exact sequence the reporter hit in #10761 by passing [\"missing_auth\", \"api_key\"] to logout:

  • Before the fix: the first iteration hits missing_auth, .getIn(...) throws on the plain-object fallback, the forEach is aborted by the thrown error, the outer try/catch swallows it — and the apiKey cookie is never cleared. So the reviewer's concern was spot on: the crash was silently hiding a stale cookie.
  • After the fix: the Map() fallback returns undefined for the getIn calls on missing_auth, the iteration continues, and api_key is processed and its cookie is correctly cleared.

I added a test (test/unit/core/plugins/auth/wrap-actions.js) that exercises that exact ordering, asserts the cookie is cleared, and would fail against the old {} fallback. I verified both directions locally. I also added a code comment explaining why the fallback is reached so it doesn't read as a mystery safety net.

Strictly speaking, the root cause is on the UI side — logoutClick submits keys for unauthorized definitions — but narrowing that behavior changes UI semantics (it also resets form state for those fields), so I kept this PR scoped to the crash. Happy to open a separate issue/PR for the logoutClick side if you'd prefer.

Commits pushed:

  • c0c74c0c test(auth): cover logout when payload contains unauthorized names
  • 76325c77 docs(auth): explain why logout payload may reference missing entries

@lukaszzazulak
Copy link
Copy Markdown
Contributor

lukaszzazulak commented Apr 20, 2026

@yogeshwaran-c it does not seem to be the case - in the logoutClick, the definitions map contains only specific security definition, not all of them, like this one here (converted using toJS() from Immutable Map):
{
"ApiKeyAuth": {
"in": "header",
"name": "X-API-Key",
"type": "apiKey"
}
}

It looks like somehow after the .map and .toArray calls, the auths array contains two elements ["ApiKeyAuth", "ApiKeyAuth"] instead of one ["ApiKeyAuth"] - and it only happens on instance described here #10761, see also my comment #10761 (comment)

it looks like there are two different implementations used of immutablejs, but not sure - take into account what I've written, but challenge my assumptions and come with your own analysis

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 (swagger-api#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.
@yogeshwaran-c
Copy link
Copy Markdown
Author

@lukaszzazulak You were right, and I was wrong about where this breaks. I went back and traced the data flow and tried to reproduce the doubled array directly. Writing up what I found.

You were right on two counts

  1. definitions in logoutClick really does contain only the one scheme the popup group is showing — not "all security definitions" as I had claimed. It comes from shownDefinitions() (populated by definitionsToAuthorize()), which pushes one-entry Maps into a List and hands each instance to a separate <Auths/>. My earlier "the payload includes unrelated schemes" reasoning was wrong.
  2. The ["ApiKeyAuth", "ApiKeyAuth"] you observed is real, and your instinct about "two different implementations of immutablejs" was correct in practice. There is just one Immutable loaded at runtime, but it is not the one swagger-ui pins.

What actually breaks

I pulled the deployed bundle at aish.li/assets/swagger-ui-Bhvg55LE.js and grepped for the Immutable version export:

Immutable:()=>ut, ... version:()=>za
za=`4.3.8`

That bundle ships Immutable 4.3.8, not the 3.8.3 that swagger-ui pins in package.json. Installing Immutable 4 side-by-side and running logoutClick's exact expression reproduces the behavior cleanly:

// Immutable v3
Map({ ApiKeyAuth: schema }).map((v, k) => k).toArray()
// → ['ApiKeyAuth']

// Immutable v4
Map({ ApiKeyAuth: schema }).map((v, k) => k).toArray()
// → [['ApiKeyAuth', 'ApiKeyAuth']]

Map#toArray() was changed in v4 from values-only to entries ([key, value] pairs). So in a v4 host, definitions.map((v, k) => k).toArray() silently hands back [['ApiKeyAuth', 'ApiKeyAuth']] instead of ['ApiKeyAuth']. That nested array then:

  • gets used as the lookup key in authorized.get(...) — misses, falls through to the default value, whose .getIn blows up (the crash @johnkruger37 hit), and
  • gets iterated by the LOGOUT reducer, where authorized.delete([[...]]) is a no-op because the real key is the string 'ApiKeyAuth' — so even without the crash, the cookie would never be cleared. This is exactly the concern you raised above.

The consumer app (Vite + Rolldown at aish.li) ends up with Immutable 4 in its final chunk most likely because its own module graph resolves a newer Immutable and the swagger-ui chunk gets tree-shaken against it. Petstore doesn't hit this because the hosted bundle still has the pinned v3.

The fix

Pushed 39bcab9 on top of the existing branch. Two changes:

  1. src/core/components/auth/auths.jsx and src/core/plugins/oas31/components/auth/auths.jsxlogoutClick now uses definitions.keySeq().toArray() instead of definitions.map((v, k) => k).toArray(). keySeq().toArray() returns the flat key list under both Immutable v3 and v4, which is the shape both the LOGOUT reducer and the cookie-cleanup loop actually need. This kills the root cause: cookies get cleared correctly and the .getIn fallback is never reached in the normal flow.
  2. src/core/plugins/auth/wrap-actions.js — kept the Map() fallback and retightened the comment. It's no longer there to paper over the broken-payload path (that path is fixed above); it's there because a defensive Immutable fallback is still the right thing for a code path that does .getIn(...) on whatever the store returns.

Tests updated:

  • Existing test/unit/core/plugins/auth/wrap-actions.js tests still pass and still guard the .getIn fallback.
  • New test/unit/core/components/auth/auths.jsx asserts the logoutClick payload shape explicitly, including a case that would have failed against the old map().toArray() pattern under v4.
npm run test:unit -- test/unit/core/components/auth/auths.jsx test/unit/core/plugins/auth/wrap-actions.js
# 10/10 passing

Let me know if you'd prefer the logoutClick change split into its own commit/PR — happy to restructure. Thanks for pushing back; the bug was real and the fix is narrower and more correct now.

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.

swagger-ui-DSpc12Sz.js:231 Error deleting cookie based apiKey from document.cookie. TypeError: o.getIn is not a function

2 participants