navree
Documentation

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.

On this page

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 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

TypeScript

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:

RequestHandlerWhat it does
POST /api/auth/loginloginSigns 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/verifyverifyTwoFactorSends {code} or {recovery_code} with the challenge token from its cookie, then sets the token cookies like login.
POST /api/auth/logoutlogoutRevokes the refresh token at the API ({all: true} ends every session), clears the cookies, answers 204.
POST /api/auth/refreshrefreshSessionRotates the cookies (204), or clears them (401 session_expired).
Anything elseproxyForwards 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:

StatuscodeWhen
401session_expiredPOST /api/auth/refresh without a refresh token, or the API refused it
401two_factor_expiredPOST /api/auth/2fa/verify without the challenge cookie
403invalid_originA non-GET request from another origin
502api_unreachableThe API didn't answer
502unexpected_responseThe API's sign-in answer had no token pair

Options

OptionDefaultMeaning
apiUrl(required)The API, e.g. http://127.0.0.1:8080.
secureCookieson when NODE_ENV is productionThe Secure flag.
allowedOriginsnoneExtra origins allowed to send non-GET requests (the public URL behind a proxy that rewrites Host).
trustedProxyHops0Reverse proxies in front of the app that append the client to X-Forwarded-For.
refreshMaxAge30 daysThe refresh cookie's lifetime, in seconds.
refreshGraceMs30000How long a finished refresh is remembered.
refreshSkewSeconds30ensureSession refreshes an access token this close to its expiry.
authPrefix/api/authThe API's auth routes.
mePath/api/auth/meThe current-user route me() calls.
workspaceHeaderX-WorkspaceThe API's workspace.header.
cookieNamesnavree_at, navree_rt, navree_2fa, navree_workspace{access, refresh, challenge, workspace}, for several apps on one domain.
upstreamPath(pathname)the same pathMaps a request path to the API path handle forwards to.
fetchglobalThis.fetchThe fetch for upstream calls (tests).

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

VariableMeaning
NAVREE_API_URL (or NUXT_NAVREE_API_URL)The API (default http://127.0.0.1:8080).
NAVREE_COOKIE_SECURE1/true or 0/false: the Secure flag. Set 0 to serve a production build over plain http.
APP_ORIGINSExtra allowed origins, comma-separated.
NAVREE_TRUSTED_PROXY_HOPSReverse proxies that append the client to X-Forwarded-For.
NAVREE_WORKSPACE_HEADERThe 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:

TypeScript
statusMeaningWhat to do
anonymousNo token cookies.Send the browser to sign-in.
activeThe access token is still valid (or opaque, so its expiry can't be read).Use accessToken.
refreshedThe 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.
expiredThe 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 and the Nuxt session gate call it for every page.

With the token, call the API from the server:

TypeScript

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:

web/src/lib/bff.test.ts
HelperWhat 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.