BuildBase on Astro: one middleware for sign-in, roles and API tokens
Astro middleware authentication with roles: one onRequest gate where admins and editors write, viewers read, strangers bounce and machines use a token.
In short
Put Astro authentication in one src/middleware.ts. It reads the BuildBase session, looks up the person's role in one workspace, and hands both to a pure decide() function you can unit-test. Admins and editors write, viewers only read, everyone else is sent away, and /api still accepts a token for machines.
Signing in is the easy half of Astro authentication. The tutorials we read got
you to "we know who this is" and stopped there, one of them at a boolean:
locals.isAdmin. Whether your admin leaks depends on the next question: may
this request do this?
Here is what happens when nobody asks it. Cloudflare's SaaS Admin Template, an
Astro dashboard for customers and subscriptions, ships with /admin open to
anyone who has the URL. Its API is guarded by a single API_TOKEN, and the
admin's own Create dialogs work by handing that token to the browser:
<CreateCustomerButton apiToken={API_TOKEN} client:only="react" />A client:only component's props are serialized into the page. So the one
secret protecting the API is in the HTML of every admin page.
We fixed that template with one middleware and one pure function. The
example
runs on Cloudflare Workers with D1. Admins and editors write. Viewers only
read, strangers are sent away, and scripts keep using the token - on /api
only.
Before you start
An Astro app with server output (the example uses the Cloudflare adapter), a
BuildBase organization, and an auth client created in the console under User
Management → Authentication, with its client ID and secret. Register
http://localhost:4321/auth/callback on the client.
Tip
Start from the working example.
astro-saas-admin
in the examples repo runs everything on this page in the SaaS Admin Template.
Watch it
working,
or copy it with npx degit buildbase-app/examples/astro-saas-admin my-app.
1. Write the rule first, as a pure function
// src/lib/buildbase.ts
export type Role = 'admin' | 'editor' | 'viewer';
export const canWrite = (role: Role | null) =>
role === 'admin' || role === 'editor';
export function decide({
pathname,
method,
apiToken,
member,
}: {
pathname: string;
method: string;
apiToken: boolean; // the request carried a valid API_TOKEN
member: { role: Role | null } | null; // null: not signed in
}) {
const isApi = pathname.startsWith('/api/');
const isAdmin = pathname === '/admin' || pathname.startsWith('/admin/');
if (!isApi && !isAdmin) return { allow: true };
if (isApi && apiToken) return { allow: true }; // machines, as before
if (!member) return { allow: false, status: 401, reason: 'sign-in' };
if (!member.role)
return { allow: false, status: 403, reason: 'not-a-member' };
if (isApi && method !== 'GET' && !canWrite(member.role)) {
return { allow: false, status: 403, reason: 'read-only' };
}
return { allow: true };
}Why pure? Because this is the part you most want tests on, and a function with
no I/O runs under node --test with no server and no mocks. The example has
seven: the landing page stays open, signed-out visitors go to sign-in, strangers are
refused, admins and editors can write, viewers read but cannot write, the token works on /api and only
there, and /administrator is not mistaken for /admin.
The roles are BuildBase's
workspace roles: admin,
editor and viewer. The admin belongs to one BuildBase workspace, named by
ADMIN_WORKSPACE_ID, and your place in that workspace is your place in the
admin.
2. The middleware gathers the inputs and applies the rule
// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
export const onRequest = defineMiddleware(async (context, next) => {
const env = context.locals.runtime.env;
const { pathname } = context.url;
const sessionId = context.cookies.get('bb_session')?.value;
context.locals.member = sessionId
? await buildbase(env).member(sessionId)
: null;
if (sessionId && !context.locals.member)
context.cookies.delete('bb_session', { path: '/' });
const apiToken =
pathname.startsWith('/api/') &&
(await validateApiToken(context.request, env.API_TOKEN));
const verdict = decide({
pathname,
method: context.request.method,
apiToken,
member: context.locals.member,
});
if (verdict.allow) return next();
if (pathname.startsWith('/api/')) {
return Response.json(
{ message: verdict.reason },
{ status: verdict.status }
);
}
return verdict.reason === 'sign-in'
? context.redirect(`/auth/sign-in?next=${encodeURIComponent(pathname)}`)
: context.redirect('/access');
});member() makes the BuildBase calls: the profile and the workspace list
together, as the Hono guide does on Workers, then
the members of the admin workspace, from which it reads the role. A session
BuildBase no longer knows comes back as null, and the cookie is cleared. Pages read Astro.locals.member for the header's role badge, and
hide the Create buttons from viewers:
---
const writable = canWrite(Astro.locals.member?.role ?? null);
---
{writable && <CreateCustomerButton client:only="react" />}No apiToken prop any more. The dialogs call /api/customers with the
session cookie, and the middleware checks it like any other request. Hiding the
button is courtesy; the 403 from decide() is the rule.
3. Sign-in: three small endpoints
/auth/sign-in stores a random state (and where to go next, same-site paths
only) in a short-lived cookie, then redirects to the URL from
AuthApi.requestAuth(). /auth/callback checks the state, exchanges the
code for a BuildBase session with the client secret, and sets bb_session as
an httpOnly, SameSite=Lax cookie.
/auth/sign-out is a POST, and it ends the BuildBase session too. The cookie
holds nothing but the session ID. The middleware checks that ID with BuildBase
on every request, so a made-up value is refused like any
session BuildBase does not know.
Open /admin signed out and you go through the hosted page (whichever of the
eight sign-in methods your org has on) and come back to /admin, with your
role in the header.
4. The first run: pick the workspace
With ADMIN_WORKSPACE_ID unset, a signed-in visitor lands on /access, which
lists their BuildBase workspaces and IDs. By default BuildBase creates one for
each person on first sign-in, and they are its admin. Put that ID in
ADMIN_WORKSPACE_ID, restart, and invite teammates into that workspace as
editors or viewers. If one of them
cannot see the workspace after
accepting, the invite is stuck in one of a few states. Because the middleware
reads the role on every request, a role change or a removal takes effect on the
teammate's next click.
Key takeaway
In Astro, authorization is one middleware and one pure function. The
middleware gathers who and what; decide() says yes or no, and it is the part
with the tests.
CSRF: what Astro covers, and what it does not
security.checkOrigin is on by default since Astro 4.9. It refuses
cross-origin POST, PUT, PATCH and DELETE requests sent with a form content type
or none at all, so a cross-site sign-out, or a hidden form posting to your
admin, gets a 403. We checked: a sign-out posted with a foreign Origin header
was refused.
It does not look at JSON requests. Those are covered differently: another site
cannot send Content-Type: application/json cross-origin without a CORS
preflight, which your API does not answer, and the SameSite=Lax session
cookie is not sent on cross-site subrequests anyway.
Where the upstream template is simpler
Upstream needs no outside service and makes no network calls to decide
access. This version asks BuildBase up to three times on each request that
carries a session cookie, in two round trips, and needs ADMIN_WORKSPACE_ID
set up once. For a busy admin, cache member() for a minute (in KV, say) and
accept that a removed teammate keeps access for that minute. And if you only
want sign-in with a fixed list of admins, Clerk's
Astro integration protects routes from its own middleware with less code than
this.
What breaks, and how you can tell
Everyone lands on /access with "not a member". ADMIN_WORKSPACE_ID
names a workspace they are not in. Check the ID the /access page listed when
the variable was unset.
Viewers get 403 on a button that should work. It is a write. decide()
allows viewers GET only, by design; make them editors.
Scripts get 401 on /api. They are not sending the token, or not on
/api. The token is accepted as Authorization: Bearer, Authorization: Token or x-api-token, and never on /admin pages.
Scripts get 403 on a POST, before the token is checked. Astro's origin
check treats a request with no Origin header as cross-site when it is sent as
a form or with no content type. Send Content-Type: application/json.
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
*.workers.dev.
Install
Add the SDK to your own app, or start from the example with
npx degit buildbase-app/examples/astro-saas-admin my-app.
npm i @buildbase/sdk