BuildBase on Fastify: authentication as a decorator plugin, tests included
Fastify authentication as a decorator plugin: hosted sign-in, @fastify/session, unchanged role checks, a fake for tests, and credits metered per request.
In short
To add authentication to a Fastify app with BuildBase, register a plugin that decorates fastify.buildbase, sign in through the hosted page, and store the user in @fastify/session in the shape your role checks already read. Tests pass a fake through the plugin options, and paid routes spend credits and answer 402.
The Fastify authentication guides we read (the plugin READMEs, Auth0's
quickstart, the tutorials that rank) all stop at the same line: the user is logged
in. None of them shows the part that decides whether the integration survives
a year: can npm test still run with the network off?
In Fastify the answer is built into the framework. Authentication is a
decorator, decorators come from plugins, and plugins take options. So put every
call to your identity provider behind fastify.buildbase, and let the test
helper hand the app a fake. We did that to
the official Fastify demo,
the Fastify team's own reference app. Its role checks did not change. Its 57
tests run on MySQL, against the fake.
Then we made one route cost money. Once you know who is calling, the next question is what they pay.
Before you start
A Fastify app with @fastify/session, a BuildBase organization, and an auth
client created in the console under User Management → Authentication, with
its client ID and secret. Register
http://localhost:3000/api/auth/buildbase/callback on the client as a
redirect URL.
Tip
Start from the working example.
fastify-demo
in the examples repo runs everything on this page in the official Fastify
demo. Watch it
working,
or copy it with npx degit buildbase-app/examples/fastify-demo my-app.
1. A plugin that owns every BuildBase call
// src/plugins/app/buildbase.ts
import { ApiVersion, AuthApi, BuildBase } from '@buildbase/sdk';
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import fp from 'fastify-plugin';
export interface BuildBaseGateway {
signInUrl(state: string): Promise<string>;
exchangeCode(code: string): Promise<string>;
profile(
sessionId: string
): Promise<{ id: string; email: string; name?: string }>;
account(sessionId: string): Promise<{
profile: object;
workspace: object | null;
credits: number | null;
}>;
spend(
sessionId: string,
amount: number,
description: string,
idempotencyKey: string
): Promise<{ balanceAfter: number }>;
revoke(sessionId: string): Promise<void>;
}
declare module 'fastify' {
interface FastifyInstance {
buildbase: BuildBaseGateway;
}
}
export default fp(
async (fastify: FastifyInstance, opts: FastifyPluginOptions) => {
// Tests pass a fake here; everything else gets the real thing.
fastify.decorate('buildbase', opts.buildbase ?? createGateway(fastify));
},
{ name: 'buildbase' }
);createGateway is the real implementation: AuthApi.requestAuth() for the
hosted URL, a fetch to /api/v1/auth/token with the client secret for the
code exchange, and BuildBase({ serverUrl, orgId }).withSession(sessionId) for
everything after.
Fastify core has no async request context (@fastify/request-context adds
one), so each call binds the session it is given, the same way
Express binds it per request and
a NestJS guard does. The client is made on
first use, which lets the API boot and serve its docs before the BuildBase
variables are filled in. The
example's plugin
is about 140 lines, most of them createGateway.
Why an interface instead of calling the SDK in routes? Because the interface is the seam. Routes depend on six methods, not on a network.
2. Sign-in: two routes and a session shape you already have
// src/routes/api/auth/index.ts
fastify.get('/buildbase/sign-in', async (request, reply) => {
const state = randomBytes(16).toString('hex');
request.session.buildbaseState = state;
await request.session.save();
return reply.redirect(await fastify.buildbase.signInUrl(state));
});
fastify.get('/buildbase/callback', async (request, reply) => {
const { code, state } = request.query;
const expected = request.session.buildbaseState;
request.session.buildbaseState = undefined;
if (!code || !sameState(state, expected)) {
return reply.unauthorized('Sign-in could not be verified.');
}
const sessionId = await fastify.buildbase.exchangeCode(code);
const profile = await fastify.buildbase.profile(sessionId);
await fastify.knex.transaction(async (trx) => {
const user = await usersRepository.upsertFromBuildBase(profile, trx);
const roles = await usersRepository.findUserRolesByEmail(user.email, trx);
request.session.user = {
id: user.id,
email: user.email,
username: user.username,
roles: roles.map((role) => role.name),
};
});
request.session.buildbaseSessionId = sessionId;
await request.session.save();
return reply.redirect('/api');
});sameState compares with timingSafeEqual and fails when either side is
missing. The state is cleared before the check, so a callback URL works once.
Look at what request.session.user holds: { id, email, username, roles }.
The demo's old password login put exactly that there. So its isModerator and
isAdmin checks, and every route behind them, work unchanged.
upsertFromBuildBase finds the user by buildbase_id, adopts an existing user
with the same email, or creates one with the basic role. One migration adds
the column and drops the password:
ALTER TABLE users
ADD COLUMN buildbase_id VARCHAR(64) NULL UNIQUE AFTER username,
DROP COLUMN password;Open /api/auth/buildbase/sign-in in a browser, sign in on the hosted page
(whichever of the eight sign-in methods your org has on), and you land on
/api greeted by name. The route-level auth hook lets /api/auth/buildbase/*
through and still refuses everything else without a session.
3. The payoff: tests with a fake BuildBase
// test/helper.ts
export function fakeBuildBase() {
const credits = new Map<string, number>();
const spent = new Set<string>();
const balance = (email: string) => credits.get(email) ?? 100;
const emailOf = (sessionId: string) => sessionId.replace(/^session:/, '');
return {
credits,
signInUrl: async (state: string) =>
`https://auth.buildbase.test/sign-in?state=${state}`,
exchangeCode: async (code: string) => `session:${code}`, // the code is the email
profile: async (sessionId: string) => {
const email = emailOf(sessionId);
return { id: `bb_${email}`, email, name: email.split('@')[0] };
},
spend: async (
sessionId: string,
amount: number,
_d: string,
key: string
) => {
const email = emailOf(sessionId);
if (spent.has(key)) return { balanceAfter: balance(email) };
if (balance(email) < amount) {
throw Object.assign(new Error('Insufficient credits'), {
code: 'INSUFFICIENT_CREDITS',
});
}
spent.add(key);
credits.set(email, balance(email) - amount);
return { balanceAfter: balance(email) };
},
revoke: async () => {},
};
}
export function config() {
return { skipOverride: true, buildbase: fakeBuildBase() };
}The helper's login(email) then drives the real routes: it calls
/api/auth/buildbase/sign-in, reads the state from the redirect, and calls
the callback with the email as the code. So the state check, the upsert, the
role lookup and the session all run in every test that signs in. Only the
network is gone.
The example's fake also backs the account screen. Its tests go further than
the happy path: a forged state, a callback with no sign-in in progress, a
seeded user adopted by email, a first-time user getting basic, and logout
revoking the BuildBase session.
Key takeaway
In Fastify, BuildBase is a decorator. Routes call fastify.buildbase, tests
swap in a fake through the plugin options, and nothing that reads
request.session.user knows sign-in moved.
4. A route that costs a credit
// src/routes/api/tasks/index.ts
async function (request, reply) {
const key = request.headers['idempotency-key'];
try {
await fastify.buildbase.spend(
request.session.buildbaseSessionId ?? '',
1,
'Task created',
typeof key === 'string' ? key : randomUUID(),
);
} catch (err) {
if ((err as { code?: string }).code === 'INSUFFICIENT_CREDITS') {
reply.code(402);
return { message: 'Out of credits.' };
}
throw err;
}
// ...then create the task, as before
}The credit is spent before the insert, so an empty workspace gets a 402 and no
task is written. The same Idempotency-Key twice is charged once. In the
recorded demo, a new user's workspace starts with 5 credits from a Workspace
Created workflow: five POST /api/tasks answer 201 and the sixth answers 402. Add the
402 to the route's response schema so Swagger shows it.
For the pricing decisions behind this, see charging per API call and a credits wallet, written for Next.js but identical from the SDK call down.
Where the alternatives are less work
Auth0's Fastify SDK registers /auth/login, /auth/logout and
/auth/callback for you and keeps the session in an encrypted cookie. Here you
write those routes, about 110 lines with the schemas. The demo's sessions also
stay in memory, as upstream's did, so production needs a session store.
@fastify/auth composes several checks with and/or logic, such as "a session or an API key". This example has one session check. If you need both, keep @fastify/auth for the composition and let BuildBase be one of the strategies it calls.
What breaks, and how you can tell
Sign-in answers 503. The BuildBase variables are not set. The route answers with the variables it needs; the rest of the API still runs.
Signing in trips the rate limiter. The demo's global rate limit counts
every request, and a sign-in is two, plus Swagger's own assets if you use the
docs. Upstream's .env.example set 4 a minute for its tests; ours sets 8.
Raise it for anything but the test suite.
The callback answers 500, not 401. The code exchange or the profile read failed (a reused code, or a network error), and the demo's error handler, upstream's, hides the message of a 5xx. Check the server log.
Everyone is signed out after a deploy. Sessions are in memory. Give
@fastify/session a store.
The hosted page refuses to send you back. The callback URL must match one
registered on the auth client exactly, path included; the port must match
too, except on localhost, where it is ignored. A wildcard can
stand in for one leftmost subdomain on a domain you own, but not for a public
suffix like *.vercel.app.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/fastify-demo my-app.
npm i @buildbase/sdk