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

[!IMPORTANT] To support same-origin cookies in local development, fluffy-chainsaw local maps all traffic through a Caddy reverse proxy on localhost:8080. For Caddy to serve your frontend on the same port, your runner must serve its static files (e.g., using express.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