firebase / firebase/apphosting-adapters

firebase-frameworks: handleAuth 500s under concurrent same-uid requests (uid-keyed LRU dispose deletes an in-flight Firebase App)

Open
#685 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
478
Forks
1.5k
Avg merge
3d 22h
Merged PRs (30d)
2

Description

### Package

`firebase-frameworks` 0.11.8 (`packages/firebase-frameworks`), running as the Cloud Run SSR backend created by `firebase deploy` for a `frameworksBackend` Hosting site. Framework is Next.js 14.2 (App Router), Node 24.

### Summary

`handleAuth` in `src/firebase-aware.ts` keys its `firebaseAppsLRU` on **uid**, and that cache is configured with `dispose: (value) => deleteApp(value)`. Concurrent requests carrying the same `__session` cookie therefore destroy each other's Firebase App while it is still being signed in, and every loser of the race throws `app/app-deleted` out of `handleFactory`, which has no catch. Express answers **HTTP 500**.

The framework never runs for those requests: `handleAuth` throws before `frameworkHandle(req, res)` is reached, so the response is Express's 176 byte default error body, not anything the app rendered.

### The race

```ts
const firebaseAppsLRU = new LRU({
max: LRU_MAX_INSTANCES,
ttl: LRU_TTL, // 5 minutes
allowStale: true,
updateAgeOnGet: true,
dispose: (value) => { deleteApp(value); }, // (1)
});

const handleAuth = async (req, res) => {
// ...
let app = firebaseAppsLRU.get(uid);
if (!app) {
const random = Math.random().toString(36).split(".")[1];
const appName = `authenticated-context:${uid}:${random}`;
app = initializeApp(undefined as any, appName);
firebaseAppsLRU.set(uid, app); // (2) disposes whatever was under `uid`
}
const auth = getAuth(app);
if (auth.currentUser?.uid !== uid) {
const customToken = await adminAuth.createCustomToken(uid);
await signInWithCustomToken(auth, customToken); // (3) throws if (1) fired for this app
}
// ...
};
```

With N concurrent same-uid requests and a cold entry for that uid:

1. All N call `firebaseAppsLRU.get(uid)` and miss, because none of them has reached its `set` yet.
2. All N build a distinct app name (the `random` suffix) and call `initializeApp`.
3. Each `set(uid, app)` at (2) evicts the value the previous request just stored, and eviction runs `dispose`, i.e. `deleteApp`, on an app that request is still using.
4. The N-1 losers are parked on `await adminAuth.createCustomToken(uid)` at that moment. When they resume into `signInWithCustomToken`, their app is gone.

Only the last setter survives. N-1 requests return 500.

### Observed in production

Six distinct uids over 14 days on one Next.js site. A representative burst, five simultaneous App Router link prefetches issued from one page right after sign in:

| request | status | latency |
| --- | --- | --- |
| `/journal?_rsc=...` | 500 | 0.162s |
| `/journal/?_rsc=...` | 500 | 0.205s |
| `/resources?_rsc=...` | 500 | 0.223s |
| `/journal/new?_rsc=...` | **200** | 0.969s |
| `/dictionary?_rsc=...` | 500 | 0.237s |

and exactly four errors, one per failed request, with four different `random` suffixes for the same uid:

```
FirebaseError: Firebase: Firebase App named
'authenticated-context::yhw01qpkh5m' already deleted (app/app-deleted).
at FirebaseAppImpl.checkDestroyed (@firebase/app/dist/esm/index.esm.js:439:33)
at get options (@firebase/app/dist/esm/index.esm.js:417:14)
at AuthImpl._getAdditionalHeaders (@firebase/auth/.../totp-5d40279f.js:3150:22)
at _performFetchWithErrorHandling (@firebase/auth/.../totp-5d40279f.js:928:13)
at _performApiRequest (@firebase/auth/.../totp-5d40279f.js:882:12)
at _performSignInRequest (@firebase/auth/.../totp-5d40279f.js:976:34)
at signInWithCustomToken$1 (@firebase/auth/.../totp-5d40279f.js:5772:12)
at signInWithCustomToken (@firebase/auth/.../totp-5d40279f.js:5815:28)
at handleAuth (file:///workspace/node_modules/firebase-frameworks/dist/firebase-aware.js:80:15)
```

4 apps created, 4 disposed by later `set`s, 4 x 500, 1 survivor. The identical 4 fail / 1 pass split reproduced for a second uid ten minutes later.

The client's immediate retry succeeds, because by then the entry is warm and every request takes the `get` hit path.

### When it fires

Any burst of same-uid requests that lands on a cold cache entry:

- the first navigation after sign in;
- the first request after `LRU_TTL` (5 minutes) of idle;
- the first burst on a newly scaled Cloud Run instance.

Next.js App Router makes this routine rather than exotic: it prefetches every in-viewport `` concurrently, so a single page load issues 5+ simultaneous same-uid requests, and Cloud Run `containerConcurrency` is 80 so they land on one instance and race each other.

### Reproduction

1. Deploy any Next.js App Router site with `frameworksBackend` and the Firebase JS SDK present, so `isUsingFirebaseJsSdk()` is true and `handleFactory` wraps the handler.
2. Sign in and set a `__session` session cookie.
3. Load a page with five or more ``s in the viewport, or just fire five concurrent requests with the same cookie.
4. Four of the five return 500 with a 176 byte body, and the logs show four `app/app-deleted` errors.

### Possible fixes

Any one of these closes it:

1. **Do not evict an app that is still in flight.** Guard the store: `if (!firebaseAppsLRU.has(uid)) firebaseAppsLRU.set(uid, app); else deleteApp(app);` so the first writer wins and later arrivals discard their own app instead of destroying somebody else's.
2. **Cache the promise, not the app.** Store an in-flight `Promise` under the uid so concurrent requests share one initialization rather than each performing their own.
3. **Catch in `handleFactory`.** Even with 1 or 2, `await handleAuth(req, res)` should not be able to turn an auth-context failure into a 500 for a page that does not need the auth context. Falling through to `frameworkHandle` unauthenticated is strictly better than a blank 500.

1 or 2 is the real fix; 3 is worth having regardless, since today any throw inside `handleAuth` takes down the request.

Contributor guide

Open the contributing guide

Research direction

Start in packages/firebase-frameworks/src/firebase-aware.ts at handleAuth and handleFactory, then reproduce the cold-cache race with concurrent requests carrying the same __session cookie. Done means same-uid bursts no longer produce app/app-deleted errors or HTTP 500 responses, and framework handling can proceed as described.

Written by the indexing model from the issue text.

Assessment

Tech stack
firebase, nextjs, node.js, typescript
Domain
authentication, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
58/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.