@b1-road/nestjs
v0.1.0-alpha.16
Published
Official NestJS toolkit for Eduzz Plat — BFF auth (OIDC + session + proxy), permission primitives, decorators, and resource client.
Readme
@b1-road/nestjs
The official NestJS toolkit for integrating with Road. It is a BFF
(Backend-for-Frontend): one RoadModule.forRoot() gives you the OIDC
login flow, a server-side session + token store, and a streaming proxy to
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. Pairs in lockstep with
@b1-road/react(cookie mode) and theb1-road/laravelSDK (a Composer package — no npm scope).
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/nestjsAlpha pre-release while the Road API contract is in alpha. The
latestdist-tag tracks the newest release, so the install above is all you need.
Peers: @nestjs/common / @nestjs/core (^10 || ^11), reflect-metadata,
rxjs, Node 20+. For the production token store, also install the optional
peer ioredis.
Quick start (server)
// app.module.ts
import { Module } from '@nestjs/common';
import { RoadModule } from '@b1-road/nestjs';
@Module({
imports: [RoadModule.forRoot()], // reads the env vars below
})
export class AppModule {}Load your .env first — Nest does not. forRoot() reads process.env
while the module is being constructed, so the load has to happen before
app.module is imported. Put it at the very top of main.ts:
// main.ts
import 'dotenv/config'; // must precede the ./app.module import
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';(npm i dotenv. Using @nestjs/config instead? Wire it with
RoadModule.forRootAsync() so the config is resolved before Road reads it.)
Skip this 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 # required in production
ROAD_ENV=sandbox # or production — this is the whole address change
# AUTH_SERVER_AUDIENCE=<project-id> # optional — set only if your Auth Server issues project-scoped (audience'd) tokensforRoot() 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 refresh token, destroys the session, clears the cookie. |
| ALL /road-api/* | Streaming proxy to Road API — attaches the user's Bearer server-side. |
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.
Serving a cookie-mode SPA? Keep it same-origin. If a
@b1-road/reactSPA consumes this BFF in cookie mode, serve the built SPA from this app (e.g.@nestjs/serve-static) or put both behind one origin. The session and CSRF cookies are origin-bound, so a static SPA hosted on a separate host can't use cookie mode — a local dev proxy hides this, and it breaks on deploy.
Using
app.setGlobalPrefix('api')? Supported. Nest applies the prefix to these mounts too, so the proxy is served at/api/road-api/*and the callback at/api/auth/road/callback. Two things follow: point the React SDK'sapiBaseUrlat/api/road-api, and register the redirect URI with the prefix (…/api/auth/road/callback) — or excludeauth/roadfrom the global prefix. The module logs this once at boot when it detects a global prefix.
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.
Controllers — three primitives
auth(), can(), road are the whole controller-author surface. The auth
source is the session, not an Authorization header — but the surface is the
same one you'd write against any header-based setup, so controllers stay
header-agnostic.
import { Controller, Get, Param } from '@nestjs/common';
import { auth, can, Read, Member } from '@b1-road/nestjs';
@Controller('business-units/:buId/members')
export class MembersController {
@Get()
list(@Param('buId') buId: string) {
auth().assert(can(Read, Member).in(buId));
return auth().road.businessUnits(buId).members.all();
}
}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 overrideDecorators (sugar)
@Get(':buId')
@RequirePermission(Read, Member, { in: 'buId' }) // type-checked
list(@CurrentUser() user: RoadUser) { ... }
@Get('me')
@SkipAuthorization() // authn still runs, authz skipped
me(@CurrentUser() user: RoadUser) { ... }
@Get('public')
@Public() // both skipped
ping() { ... }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 RoadModule.forRoot() (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. The SDK logs the diagnosis when it happens: a denial at the business-unit scope for a permission the caller holds on a subscription prints the check to use instead.
Adding Road to an app that already has auth
forRoot() registers its guards globally. That is what you want in a new
app — every route is protected by default — but in an existing one it means
every pre-existing route starts returning 401 "No active session" the moment
you mount the module. Two options:
Mark your existing routes @Public() — right when Road is meant to become
the app's auth, and you're migrating route by route.
Or turn the global gating off and opt routes in individually:
RoadModule.forRoot({ disableGlobalGuards: true })Road then guards nothing on its own: your existing auth keeps working
untouched, and the OIDC login routes (/auth/road/login,
/auth/road/callback, /auth/road/logout) still work. Opt individual routes
in with Nest's @UseGuards():
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
CurrentUser,
RequirePermission,
RoadAuthGuard,
RoadAuthorizationGuard,
Read,
Member,
type RoadUser,
} from '@b1-road/nestjs';
@Controller('reports')
export class ReportsController {
@Get()
@UseGuards(RoadAuthGuard) // authenticate against Road
list(@CurrentUser() user: RoadUser) { ... }
@Get(':buId')
@UseGuards(RoadAuthGuard, RoadAuthorizationGuard) // …and authorize
@RequirePermission(Read, Member, { in: 'buId' })
scoped(@Param('buId') buId: string) { ... }
}RoadAuthGuard is what makes the Road identity available. It establishes
the request context that @CurrentUser(), auth() and @RequirePermission()
read from, so a route that only your own auth protects has no Road identity —
@CurrentUser() is undefined there. Add RoadAuthGuard to any route that
needs it, and RoadAuthorizationGuard as well when the route uses
@RequirePermission().
One thing to know either way: the two auth systems don't share decorators.
Road's @Public() means nothing to your existing guard, and your guard's
equivalent means nothing to Road's. If your own guard runs on every route, it
will block Road's login routes — exempt the /auth/road/* prefix from it, or
the OIDC flow can't complete.
How it works
Browser ── session cookie ──▶ NestJS ── Bearer (Auth Server JWT) ──▶ Road API
│
│ token store (Redis / memory / custom)
▼
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 the auth guard fetch
the access token from the store, refresh it transparently when it's within 60s
of expiry, and attach Authorization: Bearer … server-side. Refresh rotation
is invisible to the integrator and the browser.
Configuration
forRoot() with no arguments reads everything from env. Override inline as
needed (forRootAsync({...}) exists for ConfigService injection):
RoadModule.forRoot({
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'],
// audience: process.env.AUTH_SERVER_AUDIENCE, // optional — project-scoped tokens only
// loginStateTtlSeconds: 900, // login-state window (default 15 min); raise for slow login flows
},
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' },
});Production safety — forRoot() 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'(use Redis),session.secretis shorter than 32 bytes,authServer.loginStateTtlSecondsis set to a non-positive value.
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 |
Proxy & CSRF
The proxy forwards only allowlisted path prefixes (default organization/*,
me/*, iam/identity/*, iam/authorization/*); anything else 404s before any token
lookup. Non-GET requests require a double-submit CSRF token (cookie
XSRF-TOKEN, header X-XSRF-TOKEN) — the BFF issues the cookie, the React SDK
echoes it. Both names are configurable under proxy.csrf.
Test mode — no Auth Server, no Redis, no signed JWTs
import { Test } from '@nestjs/testing';
import { RoadModule } from '@b1-road/nestjs';
import { roadScenario } from '@b1-road/nestjs/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' }); // pre-authenticated session
const moduleRef = await Test.createTestingModule({
imports: [RoadModule.forTest(scenario), MembersModule],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
await request(app.getHttpServer())
.get('/road-api/organization/business-units/bu_1/members')
.set('Cookie', await scenario.sessionCookieFor('sess_1'))
.expect(200);sessionCookieFor(id) returns a sealed session cookie for a declared session.
The OIDC round-trip itself isn't simulated — declare sessions instead.
Service mode (workers, cron, BullMQ)
Outbound calls outside a request use a service JWT:
RoadModule.forRoot({
service: {
kind: 'private_key_jwt',
clientId: process.env.ROAD_SERVICE_CLIENT_ID,
keyId: process.env.ROAD_SERVICE_KEY_ID,
privateKey: process.env.ROAD_SERVICE_PRIVATE_KEY,
},
});
// inside a worker (no request in flight)
constructor(private readonly road: RoadClient) {}
async run() { await this.road.as.service().iam.authorize({ ... }); }The SDK obtains an Auth Server JWT via client_credentials or
private_key_jwt, caches it until exp − 60s, and re-acquires on 401.
Webhooks — one controller, typed per event
import { RoadWebhookController, OnRoadEvent, type RoadEvent } from '@b1-road/nestjs';
@RoadWebhookController() // mounts POST /road/webhooks
export class RoadWebhooks {
@OnRoadEvent('organization.member.joined')
async onJoined(event: RoadEvent<'organization.member.joined'>) {
event.data.userId; // typed from the event name
}
@OnRoadEvent(['organization.member.suspended', 'organization.member.removed'])
async onGone(event: RoadEvent<'organization.member.suspended' | 'organization.member.removed'>) {
await this.cache.evict(event.data.userId);
}
}Register the controller in a module like any other. @OnRoadEvent is metadata,
not a route: the class gets one POST dispatcher that verifies the delivery
and calls the method matching the envelope's event field.
Configure secret or verify, or nothing is delivered. Verification is
fail-closed — with
neither secret nor verify configured, every delivery is rejected with 503,
so you find out on the first one instead of accepting anonymous POSTs quietly.
ROAD_WEBHOOK_SECRET=whsec_... # or: @RoadWebhookController({ secret })The default check is HMAC-SHA256 over ${timestamp}.${rawBody}, compared against
X-Road-Signature (sha256=<hex>) in constant time, with X-Road-Timestamp
required inside a 5-minute window. The two statuses separate "this endpoint
cannot verify" from "this delivery did not verify": 503 means the
endpoint is not configured or the raw body never reached it, and 401 means
verification failed. 401 does not tell you whose fault that is — a wrong
secret on your side fails exactly like a bad signature on the wire, and the
SDK cannot see the difference. Pass verify to take over entirely — it wins
over secret when both are set:
@RoadWebhookController({
path: 'integrations/road', // default: 'road/webhooks'
verify: async ({ rawBody, headers }) => myCheck(rawBody, headers),
})Enable rawBody when you bootstrap. The signature covers the exact bytes
Road sent, and JSON.stringify(req.body) is not those bytes — key order and
whitespace are both free to differ:
const app = await NestFactory.create(AppModule, { rawBody: true });Without it the SDK does not guess. It answers 503 and names the fix,
because a re-serialized body is not reliably the signed one: JSON.stringify
may happen to reproduce a compact payload whose key order already matches, and
will not for anything else. A check that passes or fails on whitespace is not a
check, and when it fails it reads like a bug on Road's side.
The controller is @Public() by design. Road delivers a signature, not a
session, so the global auth guard would reject every delivery with 401 before
the signature check ever ran. Authenticity moves to the HMAC, which still
rejects an unsigned or mis-signed body.
An event with no handler returns 200 and logs a warning, so Road adding an
event type never turns your endpoint into a source of failed deliveries.
Events
organization.invitation. created · accepted · rejected · cancelled
organization.member. joined · suspended · reinstated · removed · role-changed
bridge.grant. created · revoked
extension.install. created · uninstalled
Every delivery is { id, event, timestamp, data }; data is typed from the
event name. The catalog lives in @b1-road/types, so the API and every SDK
share one definition — import ROAD_WEBHOOK_EVENT_TYPES from there to subscribe
to all of them.
Subscribe to the bridge.grant.* and extension.install.* events if you cache
authorization answers. They are what lets a revoked grant stop being honoured
when the event reaches you, rather than whenever your cache happens to expire.
Delivery is retried and unordered, so treat the event as the trigger to
invalidate — not as a latency guarantee.
Platform Bridge
Bridge is how one platform calls another on a business unit's behalf. Your app can be on either side of it, and the SDK covers both.
Providing — you receive a bridge token and must enforce what it is allowed
to do. bridgeEnforce() verifies the token, asks Road whether the grant covers
this call, caches the answer per token, and fails closed:
import { bridgeEnforce, ROAD_BRIDGE_CONTEXT } from '@b1-road/nestjs';
// Mount it as middleware. The path carries the tenant, because resolveTenant
// reads it from the route.
consumer
.apply(
bridgeEnforce({
client: roadClient, // yours — the provider's
permission: 'read:Invoice',
resolveTenant: (req) => req.params.tenantId,
}),
)
.forRoutes('partner-api/:tenantId');resolveTenant is not optional in practice. Road knows which tenant the
token was minted for; only you know which tenant's data a request touches, so it
cannot make that check for you. strictTenancy defaults to true, which means
an Extensions-minted token is refused outright when no resolver is configured
rather than being let through unchecked.
Inside the handler the verified grant is on the request under
ROAD_BRIDGE_CONTEXT. Do not call the authorize endpoint raw instead:
bridgeEnforce owns the token verification, the tenant and acting-user
bindings, the caching policy and the fail-closed behaviour, and those are easy
to get subtly wrong.
Consuming — you exchange your own credential for a token audienced at the
provider. That is road.bridge.tokenExchange(), one of the methods on
RoadClient's bridge resource (authorize, reportAttempt, createGrant,
revokeGrant, createContract, publishContractVersion, getActiveContract,
listAudit). It is a direct passthrough to the RFC 8693 endpoint, which is the
one Road surface that is deliberately snake_case — @b1-road/types/bridge
types the request and response, so read the shapes there rather than from a
snippet.
⚠️ The scope on the exchange response is what the Auth Server minted, not
a confirmation of what you asked for. Check the status, not that field.
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 cacheErrors
import { RoadAuthzError } from '@b1-road/nestjs';
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
err.docs // undefined unless you set it; the SDK invents no URL
}
}The BFF adds three RoadAuthnError (401) subclasses: RoadSessionExpiredError
(session_expired), RoadRefreshFailedError (refresh_failed), and
RoadOidcStateError (oidc_state_mismatch). A missing/expired session yields
401 + WWW-Authenticate: Session, which the React SDK turns into your
onUnauthenticated callback. In non-prod, X-Road-Debug: 1 (or ?debug=road)
appends the DecisionTrace to 403 bodies.
