Auth (login & sessions)
Firebase-backed login with a server-owned session cookie. The backend SDK owns
the cookie end to end; the frontend SDK gives you login / logout /
getCurrentUser and never touches Firebase config directly. The same code runs
locally (Auth emulator, started by fluffy-chainsaw local) and deployed (real Firebase).
Prerequisites
@org/fluffy-chainsaw-authon the backend;@org/fluffy-chainsaw-auth/clientin the browser (plus thefirebasepackage).- For deploys: the GCP project must have Firebase Authentication / Identity Platform initialized and each OAuth provider (Google, GitHub, …) configured in the console — that's a one-time manual step fluffy-chainsaw cannot do for you.
- Frontend and backend must be same-origin (the auth routes mount at
/auth);fluffy-chainsaw firebasegenerates the Hosting rewrites that arrange this.
[!IMPORTANT] To support same-origin cookies in local development,
fluffy-chainsaw localmaps all traffic through a Caddy reverse proxy onlocalhost:8080. For Caddy to serve your frontend on the same port, your runner must serve its static files (e.g., usingexpress.static("public")).
Declare it
Auth has no code marker — it is a global singleton in fluffy-chainsaw.yaml, and each
runner opts in via uses.auth:
auth:
engine: firebase # only engine today
providers: [google, github]
session:
ttl: 7d
# expected_origin: https://example.com # pin the Origin check behind a proxy
runners:
app:
uses: { auth: true } # this runner receives the auth descriptor
trigger: { http: true }
Multiple runners can set uses.auth: true — for example, a web app and a
background worker that validates sessions. Non-HTTP runners (workers, scheduled
jobs, push-subscription handlers) do not conflict. If two HTTP-triggered
runners both set uses.auth: true, the framework cannot infer which one should
receive public /auth/** traffic, and fluffy-chainsaw scan will fail with
E_ROUTER_INVALID. Break the tie with auth.router:
auth:
engine: firebase
providers: [google]
router: web-app # this runner receives /auth/** hosting rewrites
runners:
web-app:
uses: { auth: true }
trigger: { http: true }
admin-api:
uses: { auth: true } # also verifies sessions, but is not the auth router
trigger: { http: true }
Backend
Mount the handler once; it serves GET /auth/config, POST /auth/session,
GET /auth/session, POST /auth/logout:
import express from "express";
import { auth } from "@org/fluffy-chainsaw-auth";
const app = express();
app.use(express.json());
app.use("/auth", async (req, res) => {
const result = await auth.handler({
method: req.method,
path: req.path, // mounting at "/auth" strips the prefix
headers: req.headers,
body: req.body,
});
if (!result) return res.sendStatus(404);
for (const [k, v] of Object.entries(result.headers ?? {})) res.setHeader(k, v);
res.status(result.status).json(result.body);
});
Guard routes either directly or with the Express middleware:
// direct — returns null on any failure, never throws
app.post("/api/posts", async (req, res) => {
const session = await auth.verifySession(req.headers.cookie);
if (!session) return res.status(401).json({ error: "login required" });
// session.user: { id, email?, name?, picture?, provider, claims? }
});
// middleware — also sets Cache-Control: private, no-store on the response
app.get("/api/me", auth.requireSession(), (_req, res) => {
res.json(res.locals.session.user);
});
For sensitive routes, pass { checkRevoked: true } to verifySession /
requireSession — a networked check that rejects revoked/disabled/deleted users.
Account deletion: delete the app's own data first, then the identity —
await auth.deleteUser(session.user.id) (idempotent), and send
auth.clearCookie() as the Set-Cookie header.
Frontend
import { auth } from "@org/fluffy-chainsaw-auth/client";
await auth.login("google"); // popup sign-in + server session cookie
const user = await auth.getCurrentUser(); // server session truth, or null
await auth.logout();
// fetch that transparently retries once after a silent session renewal on 401
const res = await auth.fetch("/api/posts", { method: "POST", body });
On first use the client pulls the public config from /auth/config and inits
Firebase itself — no Firebase config in your frontend code, and the emulator is
picked up automatically when running locally.
Testing & Verification (E2E)
When writing automated integration tests against the Firebase Auth emulator, standard REST sign-ups will create users with email_verified: false by default, which are rejected by the server session exchange.
To mint mock users with verified emails for testing, use the emulator-specific signInWithIdp Mock IdP endpoint:
async function mintVerifiedToken(uid: string, email: string) {
const claims = { sub: uid, email, email_verified: true };
const res = await fetch(`http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=fake-api-key`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
postBody: `id_token=${encodeURIComponent(JSON.stringify(claims))}&providerId=google.com`,
requestUri: "http://localhost",
returnIdpCredential: true,
returnSecureToken: true,
}),
});
const body = await res.json();
return body.idToken; // Can now be exchanged for a server session cookie
}
Rules
- The server cookie (
__session) is the single source of truth. Read identity viagetCurrentUser()/verifySession, never from Firebase's client-side state. - Identity is the uid.
user.idis the stable key;emailis present only when the provider supplies it verified. - Custom claims set via Firebase Admin appear read-only on
user.claims. - Sessions outlive browser state. If the browser's local Firebase identity is cleared (cleared site data, emulator restart) while a valid server cookie remains, the client SDK automatically heals on the next page load — realtime subscriptions and member-gated channels resume without re-login.
- Errors from the auth routes are structured —
{ error: { code, message } }with stable codes (auth/origin-mismatch,auth/invalid-id-token,auth/unverified-email,auth/unauthenticated, …) — branch oncode.