@b1-road/express
v0.1.0-alpha.1
Published
Official Express toolkit for Eduzz Plat — BFF auth (OIDC + session + proxy), permission middleware, typed routes, webhooks, and the resource client.
Readme
@b1-road/express
The official Express toolkit for integrating with Eduzz Plat. It is a BFF
(Backend-for-Frontend): one app.use(road()) gives you the OIDC login flow, a
server-side session + token store, and a streaming proxy to the Road API — plus
the authorization primitives for your own routes. The browser never holds a
JWT; it only carries an opaque, encrypted session cookie.
Status: alpha pre-release. BFF-only — there is no Bearer pass-through path. Built on
@b1-road/node-core, which@b1-road/nestjsalso uses, so PKCE,state, refresh and webhook signing are the same code in both drivers rather than two implementations that agree today.
New to Eduzz Plat?
Plat is Eduzz's platform layer — login, roles and permissions, and the business units your app builds on. This package is one piece of it; start here.
The fastest way in is to let your AI coding agent drive the integration:
claude mcp add road --scope user -- npx -y -p @b1-road/mcp road-mcpThat gives the agent the full integration guide plus tools to register a platform and issue its credentials.
Prefer to click through it? Create a platform in the
Dev Portal. Prefer a scaffold in your
repo? Run npx @b1-road/integrate.
Upgrading
Reaching production is now one variable, and one guard came with it.
ROAD_ENV=productionresolves the Road API on its own.ROAD_API_BASE_URLis an override for a local stack or your own gateway — if it currently names a hosted Plat host, delete it.- The SDK refuses to boot when the Road API and the Auth Server belong to different Plat environments. Sandbox and production are separate instances with separate credentials, so that pairing could never sign anyone in; it used to fail at the first real user's login instead.
- A
NODE_ENV=productiondeploy whose URLs coherently name sandbox keeps working.NODE_ENVstates a posture, not a target: it still governs cookie security, and the URLs decide which instance you talk to.
Install
npm install @b1-road/expressPeers: express (^4.18 || ^5), Node 20+. For the production token store,
also install the optional peer ioredis.
A working example
examples/express-react-starter/
— an Express BFF plus a React SPA, with the dev proxy already set up (the part
people miss: the session cookies are same-origin). The server is 60 lines and
is the whole integration.
Quick start
// server.ts
import 'dotenv/config'; // must be the FIRST import
import express from 'express';
import { road, requireAuth, auth } from '@b1-road/express';
const app = express();
app.use(road()); // reads the env vars below
app.get('/me', requireAuth(), (req, res) => {
res.json(auth().user);
});
app.listen(3000);Load your .env first — Express does not. road() reads process.env
while it is being constructed, so the load has to happen before the module that
calls it is imported. Skip it and the app throws at boot naming the exact
variables that are sitting in your .env.
# .env
AUTH_SERVER_ISSUER_URL=https://auth.example.com
AUTH_SERVER_CLIENT_ID=your-client-id
AUTH_SERVER_CLIENT_SECRET=your-client-secret
AUTH_SERVER_REDIRECT_URI=https://your-app.com/auth/road/callback
SESSION_SECRET=<32+ byte random string> # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
REDIS_URL=redis://localhost:6379 # or use a custom store — see "Token store"
ROAD_ENV=sandbox # or production — this is the whole address change
# AUTH_SERVER_AUDIENCE=<project-id> # optional — only if your Auth Server audiences its tokensroad() mounts everything for you:
| Route | What it does |
| --- | --- |
| GET /auth/road/login | Starts the OIDC PKCE login; redirects to the Auth Server. Honors ?returnTo=. |
| GET /auth/road/callback | Exchanges the code, mints the session, sets the cookie, redirects back. Restarts the login once if the login-state window expired mid-flow; on a genuine dead-end it renders a friendly retry page to browsers (JSON to API clients). |
| POST /auth/road/logout | Revokes the tokens, destroys the session, clears the cookie, 204. |
| GET /auth/road/logout | The same, then RP-initiated logout at the Auth Server so the next login isn't silently re-authenticated. |
| ALL /road-api/* | Streaming proxy to the Road API — attaches the user's Bearer server-side. |
These route paths are a compatibility contract, not a convention. The redirect URI you register with the Auth Server names the callback literally, and they are identical to the ones
@b1-road/nestjsmounts — so the same registered redirect URI works with either driver.
Auth Server setup: register an OIDC app with the redirect URI
https://your-app.com/auth/road/callbackand theopenid profile email offline_accessscopes, then drop its client id/secret into the env. Runnpx road doctorto verify the wiring.
Where to mount it
app.use(road()); // at the root
app.use('/api', road()); // under a prefix — nothing else to configureUnder a prefix, the routes become /api/auth/road/* and /api/road-api/*.
Point the React SDK's apiBaseUrl at /api/road-api and register the redirect
URI with the prefix. Express hands the middleware a mount-relative URL, so
the SDK works this out itself.
Mount it before your body parser if you can. The proxy forwards an unread
request stream straight through, which is what keeps uploads and streaming
request bodies working; once express.json() has consumed the body the proxy
re-serialises it, which is correct but no longer streaming. Everything else
works in either order.
Serving a cookie-mode SPA? Keep it same-origin. Serve the built SPA from this app (
express.static) or put both behind one origin. The session and CSRF cookies are origin-bound, so a static SPA on a separate host can't use cookie mode — a local dev proxy hides this, and it breaks on deploy.
The React companion
@b1-road/react in its cookie-mode default needs two props — no JWT, no
authMode:
<RoadProvider
apiBaseUrl="/road-api"
onUnauthenticated={() => window.location.assign('/auth/road/login')}
>
<App />
</RoadProvider>The React fetcher sends credentials: 'include', omits Authorization, and
handles CSRF automatically: the BFF issues a readable XSRF-TOKEN cookie on
login and on every proxied response, and the React fetcher echoes it as
X-XSRF-TOKEN on mutations. Reads and writes work out of the box.
Protecting routes
auth(), can() and road are the whole handler-author surface — the same
three you'd write in @b1-road/nestjs, so a handler's body doesn't change when
an app changes framework.
import { requirePermission, auth, can, Read, Member } from '@b1-road/express';
app.get(
'/business-units/:buId/members',
requirePermission(Read, Member, { in: 'buId' }),
async (req, res) => {
res.json(await auth().road.businessUnits(req.params.buId).members.all());
},
);requirePermission implies requireAuth — one middleware, not two.
const a = auth();
a.userId // string (Auth Server user id)
a.user // RoadUser (from the id_token claims)
a.token // the current access token (refreshed transparently)
a.road // user-bound RoadClient
a.assert(can(Read, Member).in(buId));
a.road.businessUnits(buId).members // for await iterable
a.road.as.service().iam.authorize(...) // outbound service-mode overrideauth() takes no argument — it reads the async context requireAuth()
established, and keeps working across await. If you ever lose that context (a
setTimeout, a queue hand-off), getAuth(req) reads it off the request
instead.
Calling the API
auth().road is a RoadClient already bound to the caller. Three things are
worth knowing, because they remove most of the code you would otherwise write.
A resource you fetched knows how to act on itself. No threading the same id through every call:
const bu = await auth().road.businessUnits.get(buId);
bu.name // it's the BusinessUnitDetail too
await bu.members.suspend(memberId);
await bu.roles.create({ name: 'Auditor', permissions: ['read:Member'] });Pagination is a for await. No cursor loop, no hasMore bookkeeping:
for await (const member of auth().road.businessUnits(buId).members) {
console.log(member.email);
}
// Bounded forms, when you want one page or a ceiling:
const first = await auth().road.businessUnits(buId).members.firstPage({ limit: 20 });
const some = await auth().road.businessUnits(buId).members.all({ maxItems: 100 });Expand replaces N+1. Ask for the children with the parent:
const bu = await auth().road.businessUnits.get(buId, { include: ['members', 'roles'] });
// ^ autocompleted,
// and a typo is a
// compile errorPer-call options live on the call, not in global config:
await auth().road.businessUnits.get(buId, { timeoutMs: 2000, signal: ac.signal });
await auth().road.businessUnits.create(input, { idempotencyKey: myKey });roadRouter() — the scope name, checked by the compiler
requirePermission(..., { in: 'buId' }) is resolved at runtime, because
app.get() doesn't tell the middleware what path it was registered on. Declare
the route through roadRouter() and the path becomes part of the type:
import { roadRouter, auth, Read, Member } from '@b1-road/express';
const router = roadRouter();
router.get(
'/business-units/:buId/members',
{ permission: [Read, Member], in: 'buId' }, // ← 'orgId' here is a compile error
async (req, res) => {
res.json(await auth().road.businessUnits(req.params.buId).members.all());
},
);
app.use(router);Two scopes: the business unit, and your platform's subscription
{ in: 'buId' } authorizes against the business unit's scope. That is right
for Road's own subjects (Member, Role, Invitation, BUSettings) and wrong
for yours.
The permissions you declared for your platform live on the subscription scope — a child scope Road creates when a business unit subscribes to your platform. Grants are collected by walking up the scope tree, never down, so a check resolved at the business unit cannot see a role granted on the subscription:
// Road's subject — business-unit scope.
requirePermission(Read, Member, { in: 'buId' });
// YOUR subject — the subscription scope where your roles actually live.
requirePermission(Read, 'Invoice', { in: { from: 'platform', buParam: 'buId' } });buParam names the route param carrying the business unit id. The platform
defaults to the platformId you pass to road() (or ROAD_PLATFORM_ID), so an
app that is a platform never names itself; add platformId: 'plat_…' to the
check to address a different one.
Test this with a non-owner. A business-unit owner holds a wildcard role that cascades down into the subscription scope, so they pass either form. The wrong one denies everyone else, which is why this surfaces in production rather than in development.
Routes on a roadRouter() are authenticated by default — a route you
forget to protect is the failure mode that matters, so opening one is explicit:
router.get('/health', { public: true }, handler); // no auth at all
router.get('/profile', { authenticate: true }, handler); // authn, no permission checkIt returns a real express.Router, so router.use(...), mounting under a
prefix, and every other middleware work unchanged.
Two things to know. The path must be a string literal for the checking to
work — that is where the param names come from, so a path held in a variable
falls back to no checking. And roadRouter() sets mergeParams: true (Express
defaults it off), so a router mounted at /business-units/:buId still sees
:buId in req.params, which is where a scope resolver looks for it.
Errors
Road's middleware answers a failed authentication or authorization itself,
rather than calling next(err). That is deliberate: Express's default error
handler turns a thrown 401 into an HTML 500, so a signed-out fetch would
see a server error instead of "you are signed out" — and @b1-road/react,
which calls onUnauthenticated on a 401, would never fire it.
Two escape hatches, both explicit:
app.use(road({ onAuthError: (err, req, res, next) => { ... } })); // globally
app.get('/x', requireAuth({ next: true }), handler); // per routeMount roadErrorHandler() last to render a RoadApiError thrown from your
handler (auth().assert(...), a road.* call that 404s) in the same shape:
app.use(roadErrorHandler());import { RoadAuthzError } from '@b1-road/express';
try {
await road.iam.authorize({ ... });
} catch (err) {
if (err instanceof RoadAuthzError) {
err.code // 'permission_denied'
err.decision // structured DecisionTrace
err.requestId // correlates to Road API logs — quote this in support
}
}In non-prod, X-Road-Debug: 1 (or ?debug=road) appends the DecisionTrace
to 403 bodies.
Two things worth knowing
road() registers no route patterns. It is a plain middleware that reads
the path and decides, because '/road-api/*' is valid on Express 4 and a
path-to-regexp error on Express 5 — so registering patterns would mean
picking a major. The cost: /auth/road/* and /road-api/* are invisible to
route-dumping tools like express-list-endpoints, and a route of your own on
those paths registered before road() will win while one registered after
never runs.
roadRouter() sets mergeParams: true. Express defaults it off, which
means a router mounted at /business-units/:buId would not see :buId in
req.params — and the resulting "scope param not present on the route" is a
confusing way to learn it.
How it works
Browser ── session cookie ──▶ Express ── Bearer (Auth Server JWT) ──▶ Road API
│
│ token store (Redis / custom / memory)
▼
Auth Server (OIDC discovery + token endpoint)The browser holds only an iron-session-sealed cookie carrying an opaque
session id. The token store, keyed by that id, holds the TokenSet (access +
refresh + expiry + cached id_token claims). The proxy and requireAuth() fetch
the access token from the store, refresh it transparently when it's within 60s
of expiry, and attach Authorization: Bearer … server-side. Concurrent
requests that all hit the refresh window share one refresh, so a rotating
refresh token is never spent twice.
Configuration
road() with no arguments reads everything from env. Override inline as needed:
app.use(road({
authServer: {
issuerUrl: process.env.AUTH_SERVER_ISSUER_URL,
clientId: process.env.AUTH_SERVER_CLIENT_ID,
clientSecret: process.env.AUTH_SERVER_CLIENT_SECRET,
redirectUri: process.env.AUTH_SERVER_REDIRECT_URI,
scopes: ['openid', 'profile', 'email', 'offline_access'],
// loginStateTtlSeconds: 900, // login-state window (default 15 min)
},
store: { driver: 'redis', url: process.env.REDIS_URL },
session: { name: 'road_session', secret: process.env.SESSION_SECRET, maxAge: 60 * 60 * 24 * 7 },
proxy: { prefix: 'road-api', allow: ['organization/*', 'me/*', 'iam/identity/*', 'iam/authorization/*'] },
api: { baseUrl: process.env.ROAD_API_BASE_URL, version: 'alpha' },
logger: console, // default: silent
}));Production safety — road() throws at boot when:
- OIDC client credentials are missing,
- the Road API and the Auth Server belong to different Eduzz Plat environments (sandbox credentials in production posture, or the reverse — the two are separate instances and that pairing cannot sign anyone in),
store.driverismemoryandNODE_ENV === 'production',session.secretis shorter than 32 bytes,api.baseUrlis unparseable or not http(s) (it is optional now:ROAD_ENVresolves a hosted one),proxy.prefixis empty (it would claim every route in your app),authServer.loginStateTtlSecondsis not positive.
Session cookie and sameSite
The session cookie defaults to SameSite=Lax, which is what you want: the
browser withholds it on cross-site POSTs, so a forged form cannot act as your
user.
Setting session.sameSite: 'none' gives that up, and a cross-origin SPA is
the usual reason to reach for it. Before you do, note what it costs: the
/auth/road/logout routes are deliberately not CSRF-protected (they are
reached by a top-level navigation), and Road's logout ends all of the
user's Road sessions — so under none, a forged request can sign someone out
of every Road-integrated app. That is a nuisance, not an escalation, but it is
a nuisance you should choose knowingly.
The better answer is almost always to keep the SPA same-origin — serve it from this app, or put both behind one domain — which is what the proxy exists to make easy.
Token store
| Driver | When | Notes |
| --- | --- | --- |
| memory | dev / tests | Map with TTL eviction (refused in production) |
| redis | production | road:session:{id}; needs the optional ioredis peer |
| custom | your own | store: { driver: 'custom', store: myStore } implementing RoadTokenStore |
You do not need Redis. Session state has to live outside the process
because you run more than one container — not because of any framework. If you
already run Postgres, DynamoDB or anything else with a key-value read, a
custom store is four methods (put / get / delete / list) and costs you
nothing extra to operate:
const store: RoadTokenStore = {
async put(id, tokens) { await db.upsert('road_sessions', { id, tokens }); },
async get(id) { return (await db.find('road_sessions', id))?.tokens ?? null; },
async delete(id) { await db.remove('road_sessions', id); },
async list() { return db.ids('road_sessions'); },
};
app.use(road({ store: { driver: 'custom', store } }));Call roadApp.close() from your SIGTERM handler so the store releases what it
holds — otherwise a rolling deploy waits for the socket to time out:
const roadApp = road();
app.use(roadApp);
process.on('SIGTERM', async () => { await roadApp.close(); server.close(); });Proxy & CSRF
The proxy forwards only allowlisted path prefixes (default organization/*,
me/*, iam/identity/*, iam/authorization/*); anything else 404s before any
token lookup, and a path containing . or .. segments is rejected outright so
it cannot normalise out of the namespace.
Non-GET requests require a signed double-submit CSRF token (cookie
XSRF-TOKEN, header X-XSRF-TOKEN). The token is an HMAC of the session under
your session secret, and the BFF verifies it is the one it issued for
this session — so an attacker who can write a cookie on your domain still
cannot forge it. Both names are configurable under proxy.csrf.
Webhooks
import { roadWebhooks } from '@b1-road/express';
const hooks = roadWebhooks({ secret: process.env.ROAD_WEBHOOK_SECRET })
.on('organization.member.joined', async (event) => {
await db.members.upsert(event.data);
})
.on(['organization.member.removed', 'organization.member.suspended'], async (event) => {
await db.members.deactivate(event.data.memberId);
});
app.post('/road/webhooks', hooks);Deliveries are verified by HMAC-SHA256 over the raw bytes, with a 5-minute
replay window. It is fail-closed: with no secret (and no
ROAD_WEBHOOK_SECRET) every delivery is refused with a 503, so you find out on
the first one rather than accepting anonymous POSTs.
The handler reads the raw body itself, so it works whether or not
express.json() is registered, and in either order. The one case it cannot
rescue is a body parser that consumed the stream and kept nothing — there it
refuses with a 503 naming the fix, rather than "verifying" against a
re-serialised body (key order and whitespace differ, so every genuine delivery
would fail). If you parse globally, keep the bytes:
app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; } }));An event type you have no handler for is acknowledged with 200 and logged — otherwise every new event Road ships would show up as a delivery failure.
The wire format
| Header | Meaning |
| --- | --- |
| X-Road-Signature | sha256=<hex> — HMAC-SHA256 over `${timestamp}.${rawBody}` |
| X-Road-Timestamp | Unix seconds (or ms). Must be within 5 minutes. |
The response tells you whose problem it is:
| Status | Meaning |
| --- | --- |
| 200 | verified and handled (or acknowledged as an unknown event type) |
| 400 | the signature verified, but the body is not a Road delivery envelope |
| 401 | the delivery did not verify — bad signature, stale timestamp, missing headers |
| 408 | the body did not finish arriving inside readTimeoutMs (default 10s) |
| 413 | the body exceeded maxBodyBytes (default 1 MiB) |
| 503 | you are not configured — no secret, or the raw body was unavailable |
Replays are bounded, not prevented. A delivery captured and re-sent inside
the 5-minute window verifies again, because nothing here remembers which
deliveries it has seen — that needs storage, and the storage is yours to
choose. Every delivery carries a unique id; if your handler is not
idempotent, record it and drop repeats. Most handlers (upserts, state
assignments) are naturally idempotent and need nothing.
A verify callback replaces the HMAC default entirely — for an Auth Server
whose signing protocol differs, or to authenticate on something else (mTLS, an
IP allowlist). It wins over secret, and it receives rawBody: string |
undefined; treat undefined as "cannot verify" rather than falling back to
the parsed body.
Events
ROAD_WEBHOOK_EVENT_TYPES is the catalog, exported for exhaustiveness checks:
import { ROAD_WEBHOOK_EVENT_TYPES, type RoadEventType } from '@b1-road/express';| Event | Fires when |
| --- | --- |
| organization.invitation.created | an invitation is sent |
| organization.invitation.accepted | an invitation is accepted |
| organization.invitation.rejected | an invitation is declined |
| organization.invitation.cancelled | an invitation is revoked |
| organization.member.joined | a member joins a business unit |
| organization.member.suspended | a member is suspended |
| organization.member.reinstated | a suspended member is restored |
| organization.member.removed | a member is removed |
| organization.member.role-changed | a member's roles change |
| bridge.grant.created | a Platform Bridge grant is issued |
| bridge.grant.revoked | a Platform Bridge grant is revoked |
| extension.install.created | an extension is installed on a business unit |
| extension.install.uninstalled | an extension is uninstalled |
RoadEventType is the union of exactly the entries in
ROAD_WEBHOOK_EVENT_TYPES, so .on('typo.event') is a compile error rather
than a handler that never fires — and the table above is generated from the
same list, so it cannot quietly fall behind it.
If you cache authorization answers, subscribe to the bridge.grant.* and
extension.install.* events. They are what lets a revoked grant stop being
honoured on the event rather than at the next cache expiry.
Health
import { roadHealth } from '@b1-road/express';
app.get('/healthz', roadHealth());200 when the Road API, the Auth Server and the token store all answer; 503
with a per-dependency reason otherwise. Every probe is bounded (5s default).
For a liveness probe, narrow it — roadHealth({ checks: ['token-store'] })
— so a brief Road outage doesn't restart a container that is fine.
Test mode — no Auth Server, no Redis, no signed JWTs
import express from 'express';
import request from 'supertest';
import { roadScenario, roadTest } from '@b1-road/express/testing';
const scenario = roadScenario()
.withUser('u_owner', { name: 'Eduardo' })
.withBusinessUnit('bu_1', { name: 'B1' })
.withRole('bu_1', 'Owner', { permissions: ['*'] })
.withMember('bu_1', 'u_owner', { roles: ['Owner'] })
.withSession('u_owner', { sessionId: 'sess_1' });
const app = express();
app.use(await roadTest(scenario)); // ← the only line that differs from production
app.use(myRoutes);
await request(app)
.get('/road-api/organization/business-units/bu_1/members')
.set('Cookie', await scenario.sessionCookieFor('sess_1'))
.expect(200);roadTest() swaps the Auth Server, the token store and the Road API for
in-memory stand-ins seeded from the scenario — but exercises the SDK's real
code paths: the same session sealing, the same proxy chain, the same permission
checks. The OIDC round-trip itself isn't simulated; declare sessions instead.
The scenario object comes from @b1-road/node-core, so a suite written against
the Nest driver ports across without touching its fixtures.
Service mode (workers, cron, queues)
Outbound calls made outside a request use a service JWT:
// `process.env` values are `string | undefined`, and these three are
// required — so read them somewhere that fails loudly rather than widening
// the type at the call site.
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
const roadApp = road({
service: {
kind: 'private_key_jwt',
clientId: required('ROAD_SERVICE_CLIENT_ID'),
keyId: required('ROAD_SERVICE_KEY_ID'),
privateKey: required('ROAD_SERVICE_PRIVATE_KEY'),
},
});
app.use(roadApp);
// inside a worker (no request in flight)
await roadApp.client.iam.authorize({ ... });The SDK obtains an Auth Server token via client_credentials or
private_key_jwt, caches it until exp − 60s, and re-acquires on 401.
Platform Bridge
import { requireBridgeGrant } from '@b1-road/express';
// The mount carries :tenantId, because resolveTenant reads it from the route.
app.use('/partner-api/:tenantId', requireBridgeGrant({
client: roadApp.client,
permission: 'read:Order',
resolveTenant: (req) => (req as { params: Record<string, string> }).params.tenantId,
}));Provider-side enforcement for brokered tokens, including the tenant and acting-user bindings Road cannot check for you — the two checks Road cannot make, because only you know whose data a request touches.
resolveTenant is not optional in practice: strictTenancy defaults to
true, so without it every Extensions-minted token is refused. If you cache
authorization answers, also subscribe to bridge.grant.* and
extension.install.* (below) — they are what lets a revoked grant stop being
honoured on the event rather than at the next cache expiry.
CLI
npx road doctor # Auth Server + token store + Road API health
npx road session list # active sessions (requires REDIS_URL)
npx road session revoke <id> # server-side logout for one session
npx road cache clear # bust the OIDC discovery cacheRelationship to the other drivers
| Package | Use it when |
| --- | --- |
| @b1-road/express | your server is Express |
| @b1-road/nestjs | your server is NestJS |
| @b1-road/node-core | you are writing a driver for another framework |
All three share one implementation of PKCE, state, refresh, the proxy chain,
the CSRF double-submit and the webhook HMAC. A fix lands once and every driver
inherits it.
