Skip to content

Commit 7d4a339

Browse files
docs(auth): build the server app with initializeBrowserOrServerApp
The server-rendering guide's step 4 becomes the one call, the five details become three, the App Check token gets a relay section, the response gets a Cache-Control header, and the manual callback stays in a "Building the app yourself" section that step 4 points at, for apps that prefer it or whose AngularFire version has no such function. The release modes say what a throwing ngOnDestroy does to a timed release.
1 parent 7f65669 commit 7d4a339

3 files changed

Lines changed: 157 additions & 51 deletions

File tree

‎docs/app-check.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ App Check's reCAPTCHA providers need a browser, so AngularFire skips App Check d
5252

5353
### Firebase requests made during server rendering
5454

55-
With no App Check instance on the server, requests to Firebase made while the server renders a page carry no App Check token. If you [enforce App Check](https://firebase.google.com/docs/app-check/enable-enforcement) for a Firebase product, those requests are rejected. Firebase's recommended fix is to hand a token from the browser to the server and pass it to `initializeServerApp` as `appCheckToken`, described in [Use App Check in SSR environments](https://firebase.google.com/docs/web/ssr-apps#use-app-check).
55+
With no App Check instance on the server, requests to Firebase made while the server renders a page carry no App Check token unless the browser relays one. If you [enforce App Check](https://firebase.google.com/docs/app-check/enable-enforcement) for a Firebase product, requests without a token are rejected. The fix is to hand the browser's token to the server and build the server app with `initializeBrowserOrServerApp`, which passes it on as `appCheckToken`. [Relaying the App Check token](auth.md#relaying-the-app-check-token) in the Authentication guide walks through it. A relayed token wins over `APP_CHECK_ON_SERVER` for Firestore, which sends the server app's token whatever App Check instance the server holds.
56+
57+
In development mode the server console says so once per process: "AngularFire skips App Check during server rendering, so server renders without a relayed App Check token send none", followed by the two ways to change that.
5658

5759
### Running App Check during server rendering
5860

‎docs/auth.md‎

Lines changed: 153 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Update the imports from `import { ... } from 'firebase/auth'` to `import { ... }
5757

5858
## Server-side Rendering
5959

60-
When Angular renders your app on the server, the server does not know which user is visiting. To render the page as that user, pass their Auth ID token to `initializeServerApp`, which gives you a Firebase app that is already signed in as them.
60+
When Angular renders your app on the server, the server does not know which user is visiting. To render the page as that user, hand their Auth ID token to `initializeBrowserOrServerApp`, which gives you a Firebase app that is already signed in as them on the server, and the ordinary Firebase app in the browser.
6161

6262
Getting the token to the server is your app's job. This guide keeps it in a cookie, because the browser attaches cookies to every request on its own.
6363

@@ -78,6 +78,12 @@ export const serverRoutes: ServerRoute[] = [
7878

7979
The rest of this guide has no effect on routes rendered any other way.
8080

81+
The request context that step 3 fills and step 4 reads is provided by `AngularNodeAppEngine` and `AngularAppEngine`, the engines `ng new --ssr` sets up. An app still rendering through the older `CommonEngine` has to provide it itself, on every render:
82+
83+
```ts
84+
engine.render({ ...renderOptions, providers: [{ provide: REQUEST_CONTEXT, useValue: { authIdToken } }] })
85+
```
86+
8187
### 2. Keep the ID token in a cookie
8288

8389
Install [js-cookie](https://github.com/js-cookie/js-cookie):
@@ -173,6 +179,8 @@ import cookieParser from 'cookie-parser';
173179
app.use(cookieParser());
174180

175181
app.use((req, res, next) => {
182+
// A page rendered for one visitor must never be served to another from a cache.
183+
res.setHeader('Cache-Control', 'private, no-store');
176184
angularApp
177185
.handle(req, { authIdToken: req.cookies?.__session })
178186
.then((response) =>
@@ -184,91 +192,186 @@ app.use((req, res, next) => {
184192

185193
Keep it where the generated block already was, below the block that serves static files, so real files are still served before Angular tries to render them. The rest of the file, including the part that starts the server, stays as it is.
186194

187-
The second argument to `handle` is what the render reads back as `REQUEST_CONTEXT`.
195+
The second argument to `handle` is what the render reads back as `REQUEST_CONTEXT`, and `initializeBrowserOrServerApp` reads the `authIdToken` key from it. If you forget the second argument, AngularFire says so once in the server console in development mode.
196+
197+
The `Cache-Control` header is not optional. A signed-in render puts that visitor's data into the HTML, so a CDN or proxy that cached the response would hand it to the next visitor.
188198

189-
### 4. Build the server app from the token
199+
### 4. Build the app with `initializeBrowserOrServerApp`
190200

191-
In `app.config.ts`, choose the Firebase app based on where the code is running, and pass that app to every Firebase provider:
201+
In `app.config.ts`, build the Firebase app with `initializeBrowserOrServerApp` and pass that app to every Firebase provider:
192202

193203
```ts
194-
import {
195-
ApplicationConfig,
196-
PLATFORM_ID,
197-
REQUEST_CONTEXT,
198-
inject,
199-
} from '@angular/core';
200-
import { isPlatformBrowser } from '@angular/common';
201-
import {
202-
FirebaseApp,
203-
initializeApp,
204-
initializeServerApp,
205-
provideFirebaseApp,
206-
} from '@angular/fire/app';
204+
import { ApplicationConfig, inject } from '@angular/core';
205+
import { FirebaseApp, initializeBrowserOrServerApp, provideFirebaseApp } from '@angular/fire/app';
207206
import { getAuth, provideAuth } from '@angular/fire/auth';
208207
import { getFirestore, provideFirestore } from '@angular/fire/firestore';
209208

210209
const firebaseConfig = { /* ...your Firebase configuration... */ };
211210

212211
export const appConfig: ApplicationConfig = {
213212
providers: [
214-
provideFirebaseApp(() => {
215-
if (isPlatformBrowser(inject(PLATFORM_ID))) {
216-
return initializeApp(firebaseConfig);
217-
}
218-
const requestContext = inject(REQUEST_CONTEXT, { optional: true }) as
219-
| { authIdToken?: string }
220-
| null;
221-
if (!requestContext?.authIdToken) {
222-
return initializeApp(firebaseConfig);
223-
}
224-
return initializeServerApp(firebaseConfig, {
225-
authIdToken: requestContext.authIdToken,
226-
releaseOnDeref: requestContext,
227-
});
228-
}),
213+
provideFirebaseApp(() => initializeBrowserOrServerApp(firebaseConfig)),
229214
provideAuth(() => getAuth(inject(FirebaseApp))),
230215
provideFirestore(() => getFirestore(inject(FirebaseApp))),
231216
// ...
232217
],
233218
};
234219
```
235220

236-
#### Five details make this work
221+
`initializeBrowserOrServerApp` returns the right kind of app for where the code is running:
222+
223+
- In the browser, the ordinary Firebase app, as `initializeApp(firebaseConfig)` would.
224+
- On the server, when the request context carries an `authIdToken`, a [`FirebaseServerApp`](https://firebase.google.com/docs/web/ssr-apps) signed in as that visitor. The same goes for an `appCheckToken`, see [Relaying the App Check token](#relaying-the-app-check-token).
225+
- On the server with neither token, which is what prerendering and a signed-out visitor look like, the ordinary Firebase app, so the page renders signed out.
226+
- On the server with a token Firebase cannot parse, the ordinary Firebase app after a warning in the server console, so the page renders signed out instead of failing the request.
227+
228+
#### Three details make this work
229+
230+
- **Keep exactly one `provideFirebaseApp`.** AngularFire hands you the app you provided only when a single one is registered, and falls back to the default app otherwise. With a second registration anywhere in your configuration, a signed-in render asks for a default app that does not exist on a freshly started server and fails.
231+
- **Pass `inject(FirebaseApp)` to every provider, not just `provideAuth`.** `ng add @angular/fire` writes them without an argument, which resolves the default app. On a signed-in request the app is a server app instead, so a provider that asks for the default app fails outright on a freshly started server.
232+
- **Never call `deleteApp` on the injected app.** `initializeBrowserOrServerApp` releases the server app itself. A second release from your code can delete an app another render is still using.
233+
234+
#### When the server app is released
235+
236+
By default the server app is released once the render has been garbage collected, which is also what the manual setup below does. Until then, a repeat request from the same visitor, such as a reload or a link opened in a new tab, reuses the app, and that reuse is what keeps such a request as fast as a signed-out one. A fresh server app costs a new Firestore connection and an account lookup, about 300 to 400 ms before the HTML is sent.
237+
238+
To release at a fixed time instead, pass `releaseAfterRender`: `0` releases the app when the render ends, which bounds memory to the renders in flight and pays that cost on every repeat request, and a positive number keeps the app for that many milliseconds after the render ends.
239+
240+
```ts
241+
provideFirebaseApp(() => initializeBrowserOrServerApp(firebaseConfig, { releaseAfterRender: 60_000 })),
242+
```
237243

238-
- **Keep exactly one `provideFirebaseApp`.** AngularFire hands you the app you provided only when a single one is registered, and falls back to the default app otherwise. A second registration anywhere in your configuration would make the server app be silently ignored.
239-
- **Pass `inject(FirebaseApp)` to every provider, not just `provideAuth`.** `ng add @angular/fire` writes them without an argument, which resolves the default app. On a signed-in request the factory above builds a server app instead, so a provider that asks for the default app fails outright on a freshly started server.
240-
- **Keep the signed-out fallback.** There is no request context when Angular prerenders a page, and no token when the visitor is signed out, so the fallback builds an ordinary Firebase app and the page renders signed out.
241-
- **Pass `releaseOnDeref`.** It tells the SDK when it may release the server app. The SDK watches the object you give it and releases once that object is garbage collected, so pass one that lives exactly as long as the render, such as the request context itself. Leave it out and the SDK requires you to call `deleteApp` yourself for each server app you create.
242-
- **The cast is needed** because Angular types `REQUEST_CONTEXT` as `unknown`.
244+
A runtime without `FinalizationRegistry` cannot release at garbage collection, so the app is released when the render ends there whatever the setting.
243245

244-
AngularFire's [sample app](https://github.com/angular/angularfire/tree/main/sample) does this differently, giving the browser and the server their own `app.config.client.ts` and `app.config.server.ts` instead of deciding at runtime, inside a single `provideFirebaseApp` factory, which of `initializeApp` and `initializeServerApp` to call. That is also fine, and it keeps the server-only code out of the browser bundle, at the cost of an extra file to wire up.
246+
With `releaseAfterRender` set, the release runs when Angular destroys the render, after your services' `ngOnDestroy` hooks. A service whose `ngOnDestroy` throws stops that teardown, so the server app is never released and one stays behind for every signed-in render. The default release does not depend on teardown and is not affected.
245247

246-
ID tokens are short-lived, and a returning visitor's browser can send one that expired while the tab was closed. The server cannot refresh it, because a user restored from an ID token has no refresh token, so Firebase logs an error and the page renders signed out. The browser then refreshes the token and the page updates.
248+
AngularFire's [sample app](https://github.com/angular/angularfire/tree/main/sample) gives the browser and the server their own `app.config.client.ts` and `app.config.server.ts`. `initializeBrowserOrServerApp` works there too, in the server file, with the client file keeping `initializeApp`.
249+
250+
ID tokens are short-lived, and a returning visitor's browser can send one that expired while the tab was closed. The server cannot refresh it, because a user restored from an ID token has no refresh token, so Firebase logs an error and the page renders signed out. The browser then refreshes the token and the page updates. A token that cannot be parsed at all, such as a cookie another site set under the same name, also renders signed out, with one warning in the server console.
251+
252+
If you would rather build the app yourself, or your AngularFire version has no `initializeBrowserOrServerApp`, see [Building the app yourself](#building-the-app-yourself) below.
247253

248254
### Using `REQUEST` instead of a cookie
249255

250-
Angular also exposes the request itself through the `REQUEST` token, so you can read the ID token from an `Authorization` header rather than a cookie. Firebase's [session management with service workers](https://firebase.google.com/docs/auth/web/service-worker-sessions) guide covers attaching that header. Steps 1, 3 and 4 stay the same apart from the server half of the factory, which becomes:
256+
Angular also exposes the request itself through the `REQUEST` token, so you can read the ID token from an `Authorization` header rather than a cookie. Firebase's [session management with service workers](https://firebase.google.com/docs/auth/web/service-worker-sessions) guide covers attaching that header. Steps 1, 3 and 4 stay the same apart from the call in step 4, which passes the token explicitly:
257+
258+
```ts
259+
import { REQUEST, inject } from '@angular/core';
260+
261+
provideFirebaseApp(() => initializeBrowserOrServerApp(firebaseConfig, {
262+
authIdToken: inject(REQUEST, { optional: true })?.headers.get('authorization')?.split('Bearer ')[1],
263+
})),
264+
```
265+
266+
`REQUEST` is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request), so headers are read with `headers.get(...)`. Angular sets it to `null` during builds, during static site generation, and during route extraction in development, and it is only supplied at all on `RenderMode.Server` routes. A missing header gives `undefined`, and `initializeBrowserOrServerApp` then looks in the request context and falls back to the ordinary app.
267+
268+
### Relaying the App Check token
269+
270+
If you [enforce App Check](https://firebase.google.com/docs/app-check/enable-enforcement) for a Firebase product, server renders need an App Check token too, or their requests are rejected. The server cannot mint one, so the browser relays its own, and `initializeBrowserOrServerApp` passes it to the server app as `appCheckToken`.
271+
272+
Relay the ordinary token from `getToken`, not a limited-use one. Replay protection only applies where a backend consumes the token, such as a callable Cloud Function with `consumeAppCheckToken`, and product enforcement for Firestore, Realtime Database and Storage does not.
273+
274+
Keep both tokens in the one `__session` cookie, since behind Firebase Hosting no other cookie reaches your server. In the cookie sync from step 2, write the cookie as JSON and rewrite it whenever either token changes:
251275

252276
```ts
253-
import { PLATFORM_ID, REQUEST, inject } from '@angular/core';
277+
import { AppCheck } from '@angular/fire/app-check';
278+
// imported from firebase/app-check so a token that never arrives does not hold the app busy
279+
import { getToken, onTokenChanged } from 'firebase/app-check';
280+
281+
const appCheck = inject(AppCheck, { optional: true });
282+
let authIdToken: string | undefined;
283+
let appCheckToken: string | undefined;
284+
285+
const writeSessionCookie = () => {
286+
if (!authIdToken && !appCheckToken) {
287+
cookies.remove('__session');
288+
return;
289+
}
290+
cookies.set('__session', JSON.stringify({ authIdToken, appCheckToken }), { secure: true, sameSite: 'lax' });
291+
};
292+
293+
idToken(auth)
294+
.pipe(takeUntilDestroyed(destroyRef))
295+
.subscribe((token) => {
296+
authIdToken = token ?? undefined;
297+
writeSessionCookie();
298+
});
299+
300+
if (appCheck) {
301+
destroyRef.onDestroy(onTokenChanged(appCheck, (result) => {
302+
appCheckToken = result.token;
303+
writeSessionCookie();
304+
}));
305+
getToken(appCheck).then((result) => {
306+
appCheckToken = result.token;
307+
writeSessionCookie();
308+
});
309+
}
310+
```
311+
312+
The `beforeAuthStateChanged` part of step 2 stays as it is, writing `authIdToken` and calling `writeSessionCookie()` instead of passing the token in. In `server.ts`, unpack the cookie and pass both keys:
313+
314+
```ts
315+
const readSessionCookie = (value: unknown): { authIdToken?: string; appCheckToken?: string } => {
316+
if (typeof value !== 'string') {
317+
return {};
318+
}
319+
try {
320+
return JSON.parse(value);
321+
} catch {
322+
return {};
323+
}
324+
};
325+
326+
app.use((req, res, next) => {
327+
res.setHeader('Cache-Control', 'private, no-store');
328+
angularApp
329+
.handle(req, readSessionCookie(req.cookies?.__session))
330+
.then((response) =>
331+
response ? writeResponseToNodeResponse(response, res) : next(),
332+
)
333+
.catch(next);
334+
});
335+
```
336+
337+
Three things to know before relaying it:
338+
339+
- **Every visitor's first server render builds a server app.** An App Check token is per device, so even signed-out visitors get a server app carrying it, and that first render pays the 300 to 400 ms described above. Relay the token only if you enforce App Check.
340+
- **A stale cookie fails the first request after the token's lifetime.** The browser rewrites the cookie only after the page's JavaScript has run and Firebase has refreshed the token. A visitor who returns after about an hour sends the old one, so that first server render's Firebase requests are rejected under enforcement, and the page shows whatever your app shows for a failed read until it hydrates and reads again. For the ID token the same stale first request only means a signed-out render. If that first render must succeed, Firebase's [service-worker relay](https://firebase.google.com/docs/web/ssr-apps#use-app-check) refreshes the token before the request leaves the browser, at the cost of a service worker your app registers itself.
341+
- **A relayed token wins over `APP_CHECK_ON_SERVER` for Firestore.** When the server app carries an `appCheckToken`, Firestore sends it whatever App Check instance the server holds.
342+
343+
### Building the app yourself
344+
345+
If you would rather build the app yourself, or your AngularFire version does not export `initializeBrowserOrServerApp`, choose the app in step 4 with your own callback. The `try` around `initializeServerApp` is what keeps a token Firebase cannot parse from failing the request with an HTTP 500, and `releaseOnDeref` is what releases the server app, the way `initializeBrowserOrServerApp` does by default:
346+
347+
```ts
348+
import { PLATFORM_ID, REQUEST_CONTEXT, inject } from '@angular/core';
349+
import { isPlatformBrowser } from '@angular/common';
350+
import { initializeApp, initializeServerApp } from '@angular/fire/app';
351+
352+
const hasAuthIdToken = (value: unknown): value is { authIdToken: string } =>
353+
typeof value === 'object' && value !== null && typeof Reflect.get(value, 'authIdToken') === 'string';
254354

255355
provideFirebaseApp(() => {
256356
if (isPlatformBrowser(inject(PLATFORM_ID))) {
257357
return initializeApp(firebaseConfig);
258358
}
259-
const request = inject(REQUEST, { optional: true });
260-
const authIdToken = request?.headers.get('authorization')?.split('Bearer ')[1];
261-
if (!authIdToken) {
359+
const requestContext = inject(REQUEST_CONTEXT, { optional: true });
360+
if (!hasAuthIdToken(requestContext) || !requestContext.authIdToken) {
361+
return initializeApp(firebaseConfig);
362+
}
363+
try {
364+
return initializeServerApp(firebaseConfig, {
365+
authIdToken: requestContext.authIdToken,
366+
releaseOnDeref: requestContext,
367+
});
368+
} catch {
262369
return initializeApp(firebaseConfig);
263370
}
264-
return initializeServerApp(firebaseConfig, {
265-
authIdToken,
266-
releaseOnDeref: request,
267-
});
268371
}),
269372
```
270373

271-
`REQUEST` is a standard [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request), so headers are read with `headers.get(...)`. Angular sets it to `null` during builds, during static site generation, and during route extraction in development, and it is only supplied at all on `RenderMode.Server` routes, so keep the signed-out fallback for those passes.
374+
Keep the signed-out fallback, since there is no request context when Angular prerenders a page and no token when the visitor is signed out, and keep the first two of the three details above. An app that already has this callback can switch to `initializeBrowserOrServerApp` in one deploy. The callback keeps working on later versions too.
272375

273376
## Convenience observables
274377

0 commit comments

Comments
 (0)