#7157·nango

[@nangohq/frontend] auth popup is centered on the display instead of the opener window (computeLayout uses window.screen)

Author: zeze-linerCreated Aug 19, 2026Updated Sep 18, 2026
LabelsStale

Summary

computeLayout() in @nangohq/frontend centers the OAuth popup on the display, not on the window that opened it. Any browser window that is not centered on its display gets a popup offset by half the difference — on a 3008×1692 display with a 900×700 window in the top-left quadrant, the popup lands 1014 px right and 456 px below where it belongs.

On multi-monitor setups the computation is also mixing coordinate spaces: window.screen.width is the width of the current display, but the left/top passed to window.open() are in the global multi-screen coordinate space whose origin is the primary display. The result is a coordinate that does not describe any point on the user's actual display, and the browser has to clamp it to a screen edge.

Version

  • @nangohq/[email protected] (what we ship)
  • Also reproduced by inspection on 0.71.4, the current latest (published 2026-08-10): dist/authModal.js is byte-identical to 0.71.0.
  • Repo HEAD packages/frontend/lib/authModal.ts still contains the same code.

The code

packages/frontend/lib/authModal.ts (lines 19–28 at HEAD; same in dist/authModal.js):

typescript
export function computeLayout({ expectedWidth, expectedHeight }: { expectedWidth: number; expectedHeight: number }): PopupLayout {
    const screenWidth = window.screen.width;      // ← display, not the opener window
    const screenHeight = window.screen.height;    // ←
    const left = screenWidth / 2 - expectedWidth / 2;
    const top = screenHeight / 2 - expectedHeight / 2;

    const computedWidth = Math.min(expectedWidth, screenWidth);
    const computedHeight = Math.min(expectedHeight, screenHeight);

    return { left: Math.max(left, 0), top: Math.max(top, 0), width: computedWidth, height: computedHeight };
}

Call site — packages/frontend/lib/index.ts (dist/index.js:109):

javascript
const modal = window.open('', '_blank', windowFeaturesToString(computeLayout({ expectedWidth: this.width, expectedHeight: this.height })));

Two separate defects:

  1. window.screen instead of the opener window. The anchor for centering a popup is the window that opens it. window.screen.width / 2 is only correct when the opener window happens to be centered on its display (e.g. maximized), which is why this often goes unnoticed.
  2. Math.max(left, 0) clamps to the primary display's origin. In the multi-screen coordinate space, displays placed to the left of / above the primary display have negative coordinates. Clamping at 0 makes it impossible to place the popup on such a display. Verified below that browsers honor negative left just fine.

Reproduction

Chrome 151.0.7922.138, macOS, three displays. Coordinates are CSS px in the global multi-screen space (primary display's top-left is the origin):

display global x-range logical size
LG 4K (primary) 0 … 3008 3008×1692
built-in laptop 3008 … 4736 1728×1117
LG 4K (portrait, left of primary) −1080 … 0 1080×1920

Steps: put the browser window somewhere that is not centered on its display, call nango.auth(providerConfigKey), then read the popup's screenX / screenY.

Measured (default width: 500, height: 600):

# opener window window.screen computeLayout popup actually opened at expected (window-centered) error
A (40, 40) 900×700 on primary 3008×1692 left=1254, top=546 (1254, 546) (240, 90) +1014, +456
B (3200, 200) 900×700 on built-in 1728×1117, availLeft=3008 left=614, top=258.5 (3008, 258) (clamped to the display's left edge) (3400, 250) −392, +8
C (−1000, 200) 900×700 on portrait 1080×1920, availLeft=−1080 left=290, top=660 (−500, 660) (clamped to the display's right edge) (−800, 250) +300, +410

In B and C the computed left (614 / 290) is a coordinate that lies on the primary display, because it was derived from the current display's width but is interpreted in the global space. The popup can never be at that location for that user, so it ends up jammed against a screen edge.

Case A is the one users report: the popup opens down-and-right of the page that opened it, overlapping unrelated content.

Impact

Verified severity — user-visible misplacement, not loss of the flow. Chrome clamps the popup into the work area of the display the opener is on, so on Chrome/macOS the popup never lands off-screen or on a different monitor. Measured clamping (opener on the primary display):

  • requested left=-5000 → opened at 0
  • requested left=9000 → opened at 2508 (= 3008 − 500)
  • requested top=9000 → opened at 1026 (= 30 + 1662 − 666)
  • opener on the built-in display, requested left=0 (and left=-1000) → opened at 3008

So the practical damage is: on a secondary display the consent window is pinned to a screen edge far from the page that launched it, and on a primary display it is offset by half the window's offset from screen center. Users lose the popup, click the launch button again, or abandon the connection. (Unverified: Firefox, Safari, Windows and Linux. If any of them do not clamp the way Chrome does, the mismatched coordinate becomes an off-screen popup and the OAuth flow becomes uncompletable. We have not tested those.)

There is no way to work around this from the public API

NangoOptions exposes only host, websocketsPath, width, height, debug, and the credential fields; AuthOptions exposes only detectClosedAuthWindow plus connection config. Neither has a position, an anchor, or a hook to supply window features. (Checked dist/index.d.ts + dist/types.d.ts on both 0.71.0 and 0.71.4 — identical.) The only workarounds available to an integrator are patching the package or monkey-patching window.open, both of which we'd rather not ship.

Suggested fix

Anchor on the opener window and stop clamping at the primary origin:

typescript
export function computeLayout({ expectedWidth, expectedHeight }: { expectedWidth: number; expectedHeight: number }): PopupLayout {
    // Anchor on the window that opens the popup, not on the display.
    // screenX/screenY and the left/top passed to window.open() are both in the multi-screen
    // coordinate space, so this stays correct when the opener sits on a secondary display.
    const openerLeft = window.screenX ?? window.screenLeft ?? 0;
    const openerTop = window.screenY ?? window.screenTop ?? 0;
    const openerWidth = window.outerWidth || document.documentElement.clientWidth;
    const openerHeight = window.outerHeight || document.documentElement.clientHeight;

    const width = Math.min(expectedWidth, window.screen.availWidth || expectedWidth);
    const height = Math.min(expectedHeight, window.screen.availHeight || expectedHeight);

    // No Math.max(..., 0): displays left of / above the primary one have negative coordinates,
    // and clamping at 0 would throw the popup onto the primary display.
    return {
        left: Math.round(openerLeft + (openerWidth - width) / 2),
        top: Math.round(openerTop + (openerHeight - height) / 2),
        width,
        height
    };
}

Verification of the two claims this rests on, same setup as above:

  • Window-centered coordinates are honored exactly. Opener (40, 40) 900×700 on the primary display, requested left=240, top=90 → popup opened at exactly (240, 90).
  • Negative left is honored. Opener (−1000, 200) on the portrait display, requested left=−800, top=250 → popup opened at exactly (−800, 250). Today's Math.max(left, 0) would have turned that into 0, i.e. a different monitor.

Two smaller things the patch also cleans up:

  • top is currently emitted as a non-integer (top=258.5 in case B above) because of the / 2. Math.round avoids relying on the browser's parsing of a fractional pixel value.
  • Browsers add chrome to a popup (Chrome opened outerHeight = 666 for height=600), so centering the requested size leaves the popup ~33 px above true center. That is well within what anyone would call centered, and the actual outer size is not knowable before the window exists.

We're happy to supply the patch as a PR if you'd rather have it that way — CONTRIBUTING.md says external bug-fix PRs aren't reviewed, which is why this is an issue.

Prior art

#1303 reported the same symptom in November 2023 and was closed with "Moved to internal issue tracker". computeLayout is unchanged since. That report was a screenshot without a mechanism, so we're filing again with the code path and measurements in case the internal ticket doesn't have them.