@b1-road/nestjs
v0.1.0-alpha.3
Published
Official NestJS toolkit for integrating with Road — BFF auth (OIDC + session + proxy), 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:
0.1.0-alpha.0. 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).
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 {}# .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_API_BASE_URL=https://api.road.b1.app
# 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. |
| 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.
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() { ... }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
},
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/*', '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,
store.driverismemoryandNODE_ENV === 'production'(use Redis),session.secretis shorter than 32 bytes.
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/*,
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.
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
err.docs // https://road.b1.app/errors/permission-denied
}
}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.
