firebase / firebase/firebase-js-sdk

[auth] _openIframe returns a promise gapi's Context.open() never adopts, so the 5 s ping timeout rejects an unowned promise (unhandled auth/network-request-failed on Safari)

Open
#10,383 0 comments 0 reactions 0 assignees View on GitHub
api: auth needs-attention stack:React
Dominant language
TypeScript
Stars
5.1k
Forks
1k
Avg merge
2d 21h
Merged PRs (30d)
37

Description

### Operating System

macOS 15.

### Browser Version

Safari 18.3.1 (production event); reproduced with a Safari user agent in Chromium against a controlled `__/auth/iframe` child.

### Firebase SDK Version

`firebase` 11.10.0 (`@firebase/auth` 1.10.8). `packages/auth/src/platform_browser/iframe/iframe.ts` is unchanged in this respect on `main` at the time of writing, so upgrading to 12.x / `@firebase/auth` 1.13.6 does not remove it.

### Firebase SDK Product

Authentication

### Describe your project's tooling

React 18 + Vite 5 web app, ES modules. Sentry browser SDK with the default `globalHandlers` integration, so `window.onunhandledrejection` is reported.

### Describe the problem

On Safari, iOS and mobile browsers, `getAuth()` initialises the popup/redirect resolver proactively (`_shouldInitProactively()` = `_isMobileBrowser() || _isSafari() || _isIOS()`), which opens the hidden `https:///__/auth/iframe` through gapi. `_openIframe` is written as:

`packages/auth/src/platform_browser/iframe/iframe.ts`

```ts
return context.open(
{ where: document.body, url: getIframeUrl(auth), messageHandlersFilter: ..., attributes: ..., dontclear: true },
(iframe: gapi.iframes.Iframe) =>
new Promise(async (resolve, reject) => {
await iframe.restyle({ setHideOnLeave: false });
const networkError = _createError(auth, AuthErrorCode.NETWORK_REQUEST_FAILED);
let networkErrorTimer = setTimeout(() => { reject(networkError); }, PING_TIMEOUT.get());
function clearTimerAndResolve(): void { clearTimeout(networkErrorTimer); resolve(iframe); }
iframe.ping(clearTimerAndResolve).then(clearTimerAndResolve, () => { reject(networkError); });
})
);
```

The promise returned from that callback is expected to be adopted by `Context.open()`. **It is not.** Dumping the live `gapi.iframes` module (build `gapi.lb.en.zhTT8Br0Ho8.O`) gives, verbatim:

```js
Context.prototype.open = function (a, b) {
...;
var c = new ao(a);
b = zo(this, c, b);
var d = new Nn(b);
...
c.U.waitForOnload && cn(c.uo(), function () { d.resolve(h) });
var h = this.openChild(a);
c.U.waitForOnload || d.resolve(h);
return d.promise
}
```

`d` is a deferred built *around* the callback. Firebase does not pass `waitForOnload`, so `d.resolve(h)` runs the callback with the `Iframe` object and resolves `open()`'s promise with `h` **immediately**. The value the callback returns, Firebase's `new Promise(...)`, is never chained. From that moment the inner promise has no owner, and the `setTimeout(... reject(networkError) ...)` inside it can only produce an unhandled rejection.

Timing confirms `open()` resolves synchronously with respect to the iframe: in every run `onAuthStateChanged` fired ~2 ms after the iframe element was inserted (205 ms insert / 207 ms auth ready), and deliberately stalling the iframe document for 20 s still had auth ready at 224 ms. So the iframe's readiness never gates auth init, the only observable effect of this code path is the rejection.

The trigger is a child frame that answers the `restyle` RPC but not the `ping` RPC within `PING_TIMEOUT` (5 s for every web browser; `Delay.isMobile` is `isMobileCordova() || isReactNative()`, so the 15 s value only applies to Cordova/React Native). A child that answers neither parks the callback at `await iframe.restyle(...)` forever and produces nothing.

The production event: `FirebaseError: Firebase: Error (auth/network-request-failed)`, `mechanism: auto.browser.global_handlers.onunhandledrejection`, macOS Safari 18.3.1, source-mapped to the `_createError(auth, "network-request-failed")` line inside the `_openIframe` callback, fired 42 s after page load with no user interaction in between (no popup, no redirect, no sign-in call). The application cannot catch it: it never called anything that returns this promise.

Related: #8034 (`Unhandled Promise Rejection in Safari`, closed 2025-02-10) is the same symptom class; the code path is still present.

### Steps and code to reproduce the issue

Serve a page with a Safari user agent that loads Firebase Auth, and serve a `__/auth/iframe` child that **answers `_g_restyle` but never answers `_g_ping`**. The gapi RPC transport is `postMessage`, so the child only has to reply to one of the two.

Minimal child (served at the app's `authDomain` origin, or at any origin you point `authDomain` at for the test):

```html

window.addEventListener('message', (e) => {
let data; try { data = JSON.parse(e.data); } catch { return; }
// Reply to restyle, stay silent on ping.
if (data && typeof data.f === 'string' && data.f.indexOf('_g_restyle') === 0) {
e.source.postMessage(JSON.stringify({ s: 'gapi.iframes', f: data.c, a: [{}] }), '*');
}
});

```

Page:

```js
import { initializeApp } from 'firebase/app';
import { getAuth, onAuthStateChanged } from 'firebase/auth';

window.addEventListener('unhandledrejection', (e) => {
console.log('UNHANDLED', e.reason && e.reason.code, e.reason && e.reason.message);
});

const auth = getAuth(initializeApp({ /* any config; authDomain points at the child above */ }));
onAuthStateChanged(auth, () => console.log('auth ready'));
// No sign-in call. Nothing else.
```

**Expected:** either the 5 s ping timeout surfaces on a promise the caller owns (so an application can handle it, or so it is simply discarded with the rest of the proactive-init work), or the proactive path does not create a rejection at all when nobody asked for a popup.

**Actual:**

```
auth ready (t = 265 ms)
UNHANDLED auth/network-request-failed Firebase: Error (auth/network-request-failed). (t = 5262 ms)
```

Exactly the production signature, and with `onAuthStateChanged` already long since fired, i.e. the rejection is not connected to anything the application is waiting on.

Control cases, for scoping:

| child behaviour | result |
|---|---|
| blank document, answers nothing | no rejection (callback parks at `await iframe.restyle`) |
| document stalled 20 s | no rejection; auth ready at 222 ms |
| request aborted | no rejection |
| real Firebase-hosted iframe (both RPCs answered) | no rejection |
| **answers `restyle`, not `ping`** | **1 unhandled `auth/network-request-failed` at 5.26 s** |
| Chrome UA (proactive init skipped) | no iframe, no rejection |

### Suggested fix

Own the deferred instead of returning a promise `Context.open()` discards:

```ts
return new Promise((resolve, reject) => {
context.open(
{ where: document.body, url: getIframeUrl(auth), messageHandlersFilter: ..., attributes: ..., dontclear: true },
(iframe: gapi.iframes.Iframe) => {
void (async () => {
await iframe.restyle({ setHideOnLeave: false });
const networkError = _createError(auth, AuthErrorCode.NETWORK_REQUEST_FAILED);
let networkErrorTimer: number | null = window.setTimeout(() => {
networkErrorTimer = null;
reject(networkError); // the OUTER deferred
}, PING_TIMEOUT.get());
const settle = (): void => {
if (networkErrorTimer !== null) { clearTimeout(networkErrorTimer); networkErrorTimer = null; }
resolve(iframe); // the OUTER deferred
};
iframe.ping(settle).then(settle, () => reject(networkError));
})().catch(reject);
}
);
});
```

Now `_initializeWithPersistence`'s existing `try/catch` around `resolver._initialize(auth)` sees the timeout, and `signInWithPopup` / `signInWithRedirect` / `getRedirectResult` get a real rejection they can act on, instead of a rejection that reaches nobody.

Two smaller points that would also help, independently of the above:

- `await iframe.restyle(...)` has no timeout, so a child that replies to nothing leaves the callback parked forever and `initAndGetManager` can never settle. A bound there (the same `PING_TIMEOUT`, or a separate one) would make the proactive path terminate in all cases.
- `_loadGapi`'s underlying `loadJS` still carries `// TODO: consider adding timeout support & cancellation`. Because proactive init awaits it *before* `initializeCurrentUser`, an `apis.google.com/js/api.js` request that hangs, or that returns a non-gapi 200, e.g. a captive portal, means `onAuthStateChanged` never fires at all on Safari/iOS/Android (measured: no auth event in a 45 s window; desktop Chrome, which skips the path, is ready in 4 ms). A blocked request is fine, since `onerror` fires. That is a separate defect and I am happy to file it separately if preferred.

Happy to open a PR if the maintainers agree on the shape.

Contributor guide

Open the contributing guide

Research direction

Start in packages/auth/src/platform_browser/iframe/iframe.ts at _openIframe, then trace how it is called during proactive initialization and through _initializeWithPersistence. Reproduce with a controlled auth iframe that answers restyle but not ping. Done means the timeout is owned by the relevant initialization or caller path and no unhandled auth/network-request-failed rejection reaches window.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
authentication
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.