[@nangohq/frontend] auth popup is centered on the display instead of the opener window (computeLayout uses window.screen)
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 currentlatest(published 2026-08-10):dist/authModal.jsis byte-identical to0.71.0. - Repo
HEADpackages/frontend/lib/authModal.tsstill contains the same code.
The code
packages/frontend/lib/authModal.ts (lines 19–28 at HEAD; same in dist/authModal.js):
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):
const modal = window.open('', '_blank', windowFeaturesToString(computeLayout({ expectedWidth: this.width, expectedHeight: this.height })));Two separate defects:
window.screeninstead of the opener window. The anchor for centering a popup is the window that opens it.window.screen.width / 2is only correct when the opener window happens to be centered on its display (e.g. maximized), which is why this often goes unnoticed.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 at0makes it impossible to place the popup on such a display. Verified below that browsers honor negativeleftjust 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 at0 - requested
left=9000→ opened at2508(= 3008 − 500) - requested
top=9000→ opened at1026(= 30 + 1662 − 666) - opener on the built-in display, requested
left=0(andleft=-1000) → opened at3008
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:
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, requestedleft=240, top=90→ popup opened at exactly(240, 90). - Negative
leftis honored. Opener(−1000, 200)on the portrait display, requestedleft=−800, top=250→ popup opened at exactly(−800, 250). Today'sMath.max(left, 0)would have turned that into0, i.e. a different monitor.
Two smaller things the patch also cleans up:
topis currently emitted as a non-integer (top=258.5in case B above) because of the/ 2.Math.roundavoids relying on the browser's parsing of a fractional pixel value.- Browsers add chrome to a popup (Chrome opened
outerHeight = 666forheight=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.
Source: NangoHQ/nango