# Auth and the BFF

> `@navree/bff`: httpOnly token cookies, single-flight refresh, two-factor sign-in, the Origin check, a streaming proxy, sessions for server rendering, and test helpers.

A token in `localStorage` is one cross-site scripting bug away from leaking every session. `@navree/bff` keeps the tokens on the server: it is the [backend-for-frontend](https://navree.com/docs/patterns/backend-for-frontend.md) of a web app on a Navree API, written on web-standard `Request` and `Response`. `@navree/next` mounts it as route handlers and `@navree/nuxt` as Nitro routes; any server that speaks web requests can mount it too.

## Creating the BFF

```ts
import { createBff, bffOptionsFromEnv } from "@navree/bff";

export const bff = createBff(bffOptionsFromEnv());

// In a route that receives every /api/* and /broadcasting/auth request:
export const handle = (request: Request) => bff.handle(request);
```

Create one BFF per server process and share it: the refresh map that keeps refreshes single-flight lives in it. `@navree/next` and `@navree/nuxt` create theirs on the first request.

`bff.handle(request)` routes by path:

| Request | Handler | What it does |
|---|---|---|
| `POST /api/auth/login` | `login` | Signs in at the API, sets the token cookies, answers `{user}`; a two-factor account answers `{two_factor_required: true, expires_in}`. |
| `POST /api/auth/2fa/verify` | `verifyTwoFactor` | Sends `{code}` or `{recovery_code}` with the challenge token from its cookie, then sets the token cookies like `login`. |
| `POST /api/auth/logout` | `logout` | Revokes the refresh token at the API (`{all: true}` ends every session), clears the cookies, answers 204. |
| `POST /api/auth/refresh` | `refreshSession` | Rotates the cookies (204), or clears them (401 `session_expired`). |
| Anything else | `proxy` | Forwards to the API at the same path with the bearer token: `/api/*`, `/broadcasting/auth`, a server-rendered tool mounted at `/admin`. |

## What the BFF guarantees

**Cookies.** The access token is in `navree_at` (`Max-Age` = the token's `expires_in`), the refresh token in `navree_rt` (30 days), both `HttpOnly; SameSite=Lax; Path=/`, and `Secure` in production. A pending two-factor challenge waits in `navree_2fa` on `Path=/api/auth/2fa`, so only the code step receives it. A token pair in any auth response (a password change answers one) replaces the cookies and is stripped from the body.

**Refresh.** When the access cookie is gone but the refresh cookie is left, the proxy refreshes before forwarding; on a 401 it refreshes once and retries. Navree rotates refresh tokens and revokes the whole family when one is spent twice, so the refresh is **single-flight per refresh token**: concurrent requests holding the same cookie share one refresh call, and a finished refresh is remembered for 30 seconds for requests still carrying the old cookie. A failed refresh clears both cookies and the response is the 401, so the browser goes to sign-in.

**CSRF.** A non-GET request must come from the app's own origin: its `Origin` header's host must be the host the browser addressed (`X-Forwarded-Host`, else `Host`), or be listed in `allowedOrigins`. Anything else gets `403 invalid_origin` before the API is called.

**Pass-through.** Status and body stream through unchanged, so CSV exports and server-sent events arrive as the API sends them. The response headers that pass are `cache-control`, `content-disposition`, `content-language`, `content-security-policy`, `content-type`, `etag`, `last-modified`, `location`, `referrer-policy`, `retry-after`, `vary`, `x-content-type-options`, `x-frame-options`, `x-request-id` and every `x-ratelimit-*`; the API's own `Set-Cookie` never reaches the browser. Upstream requests use `cache: "no-store"`.

**Request headers.** `accept`, `accept-language`, `content-type`, `if-match`, `if-none-match`, `if-modified-since`, `last-event-id`, `user-agent` and `x-request-id` are forwarded, plus the workspace: the request's `X-Workspace` header, or the `navree_workspace` cookie when there is none. Cookies never leave the BFF.

**Client address.** The browser's own `X-Forwarded-For` and `X-Real-IP` never reach the API. Behind `trustedProxyHops` reverse proxies that append the client to `X-Forwarded-For`, the BFF sends the API a single `X-Forwarded-For` with the address the outermost one saw. List the BFF's address in the API's `http.trusted_proxies` so the login throttle and per-IP rate limits apply per browser.

### The BFF's own errors

The BFF answers some requests itself, in Navree's error shape:

| Status | `code` | When |
|---|---|---|
| 401 | `session_expired` | `POST /api/auth/refresh` without a refresh token, or the API refused it |
| 401 | `two_factor_expired` | `POST /api/auth/2fa/verify` without the challenge cookie |
| 403 | `invalid_origin` | A non-GET request from another origin |
| 502 | `api_unreachable` | The API didn't answer |
| 502 | `unexpected_response` | The API's sign-in answer had no token pair |

## Options

| Option | Default | Meaning |
|---|---|---|
| `apiUrl` | (required) | The API, e.g. `http://127.0.0.1:8080`. |
| `secureCookies` | on when `NODE_ENV` is `production` | The `Secure` flag. |
| `allowedOrigins` | none | Extra origins allowed to send non-GET requests (the public URL behind a proxy that rewrites `Host`). |
| `trustedProxyHops` | `0` | Reverse proxies in front of the app that append the client to `X-Forwarded-For`. |
| `refreshMaxAge` | 30 days | The refresh cookie's lifetime, in seconds. |
| `refreshGraceMs` | `30000` | How long a finished refresh is remembered. |
| `refreshSkewSeconds` | `30` | `ensureSession` refreshes an access token this close to its expiry. |
| `authPrefix` | `/api/auth` | The API's auth routes. |
| `mePath` | `/api/auth/me` | The current-user route `me()` calls. |
| `workspaceHeader` | `X-Workspace` | The API's `workspace.header`. |
| `cookieNames` | `navree_at`, `navree_rt`, `navree_2fa`, `navree_workspace` | `{access, refresh, challenge, workspace}`, for several apps on one domain. |
| `upstreamPath(pathname)` | the same path | Maps a request path to the API path `handle` forwards to. |
| `fetch` | `globalThis.fetch` | The fetch for upstream calls (tests). |

`bffOptionsFromEnv(env?, defaults?)` reads them from the environment at request time, so the API can move without a rebuild:

| Variable | Meaning |
|---|---|
| `NAVREE_API_URL` (or `NUXT_NAVREE_API_URL`) | The API (default `http://127.0.0.1:8080`). |
| `NAVREE_COOKIE_SECURE` | `1`/`true` or `0`/`false`: the `Secure` flag. Set `0` to serve a production build over plain http. |
| `APP_ORIGINS` | Extra allowed origins, comma-separated. |
| `NAVREE_TRUSTED_PROXY_HOPS` | Reverse proxies that append the client to `X-Forwarded-For`. |
| `NAVREE_WORKSPACE_HEADER` | The API's `workspace.header`. |

## Sessions on the server

Server rendering, server routes and middleware need a usable token for the request they handle. `bff.ensureSession(request)` gives one:

```ts
const session = await bff.ensureSession(request);
// { status: "active" | "refreshed" | "anonymous" | "expired", accessToken?, setCookie: string[], cookieHeader? }
```

| `status` | Meaning | What to do |
|---|---|---|
| `anonymous` | No token cookies. | Send the browser to sign-in. |
| `active` | The access token is still valid (or opaque, so its expiry can't be read). | Use `accessToken`. |
| `refreshed` | The access token had expired (or was within `refreshSkewSeconds`), and the refresh worked. | Add `setCookie` to the response, use `cookieHeader` for the rest of this request, use `accessToken`. |
| `expired` | The refresh failed. | Add `setCookie` (it clears the cookies) and send the browser to sign-in. |

`ensureSession` reads the JWT's `exp` without verifying the signature, only to decide whether to refresh; the API verifies every token it receives. It shares the refresh map with the proxy, so a page and its first API call never spend the same refresh token twice. The [Next.js proxy](https://navree.com/docs/frontend/nextjs.md#the-proxy) and the [Nuxt session gate](https://navree.com/docs/frontend/nuxt.md#the-session-gate) call it for every page.

With the token, call the API from the server:

```ts
const user = await bff.me(session.accessToken, bff.workspaceOf(request)); // null when signed out
const response = await bff.api("/api/v1/deals?page=2", { accessToken: session.accessToken, workspace: "acme" });
```

`me()` answers `null` for a missing or refused token and throws when the API is down, so a page can tell "signed out" from "unreachable".

## Testing

`@navree/bff/testing` has the helpers the package's own tests use:

```ts title="web/src/lib/bff.test.ts"
import { createBff } from "@navree/bff";
import { browserRequest, fakeNavree, readSetCookies, tokenPair } from "@navree/bff/testing";
import { expect, it } from "vitest";

it("signs in with httpOnly cookies", async () => {
  const navree = fakeNavree({
    "POST /api/auth/login": () => Response.json({ user: { id: "u1" }, ...tokenPair(1) }),
  });
  const bff = createBff({ apiUrl: "http://api.test", fetch: navree.fetch });

  const response = await bff.handle(
    browserRequest("http://app.test", "/api/auth/login", { method: "POST", body: { email: "a@x.test", password: "pw" } }),
  );

  expect(await response.json()).toEqual({ user: { id: "u1" } });
  expect(readSetCookies(response).navree_at.attrs).toContain("HttpOnly");
  expect(navree.calls[0].headers.get("authorization")).toBeNull();
});
```

| Helper | What it is |
|---|---|
| `fakeNavree(routes)` | A scripted API keyed by `"METHOD /path"`; records every call in `calls` and answers 404 in Navree's shape otherwise. |
| `browserRequest(appUrl, path, {method, cookies, origin, body, headers})` | A request as a browser sends it, with the app's own `Origin` on non-GET requests (`origin: null` sends none). |
| `readSetCookies(response)` | The `Set-Cookie` headers by name: `{value, attrs}`. |
| `tokenPair(n)` | Navree's token response with `AT{n}` and `RT{n}`. |
| `unsignedJwt(claims)` | A JWT the API would never accept, for testing expiry handling. |

## Related

- [Backend for frontend](https://navree.com/docs/patterns/backend-for-frontend.md): Why a BFF, and how sign-in, refresh and real time fit together.
- [Authentication](https://navree.com/docs/security/authentication.md): The API's sign-in, refresh-token families and two-factor.
