Skip to content

Auth architecture

Authentication is pluggable. The framework defines an AuthProvider interface in @drawnagency/primitives; client sites wire up a concrete adapter in portal.config.mjs; the core middleware and API routes call through the interface without knowing which adapter is in use.

AuthProvider is the contract any auth adapter must satisfy. It is defined in packages/primitives/src/auth/ and re-exported from the root @drawnagency/primitives entry. Key method groups:

  • resolveSession(ctx) — extract and verify the current session from cookies
  • signIn(method, ctx) — handle a sign-in attempt
  • signOut(ctx) — clear session cookies
  • audiences.list() — list viewer audiences
  • audiences.verify?(name, password) — verify an audience-level password. .env mode only, and optional: supabaseAuth() retired the audience-level password and does not implement it
  • audiences.credentials? — named viewer sign-ins; presence puts the site in credential mode (see below)
  • passwordEnabled.get() — whether the viewer password gate is active

supabaseAuth() — @drawnagency/auth-supabase

Section titled “supabaseAuth() — @drawnagency/auth-supabase”

The production adapter. Uses Supabase Auth for editor authentication (email/OAuth flows) and manages viewer audiences in the Supabase database.

import { supabaseAuth } from "@drawnagency/auth-supabase";

Env vars required at runtime: SUPABASE_URL, SUPABASE_ANON_KEY, and either SUPABASE_SERVICE_ROLE_KEY (standalone) or PLATFORM_API_URL + PLATFORM_API_KEY + PORTAL_SITE_ID (platform mode). Env is validated lazily — on the first auth method call, not at import time — so portal.config.mjs can be loaded in browser contexts without throwing.

OAuth redirect derives its origin from the live request (url.origin), not from import.meta.env.SITE. This is required because Netlify’s build-time URL env var may resolve to the *.netlify.app domain instead of the custom domain, which would make the OAuth redirect URI unmatched in the Supabase allow-list and break PKCE cookie cross-origin.

createPasswordAuth() — @drawnagency/core/password

Section titled “createPasswordAuth() — @drawnagency/core/password”

The password-only adapter. No Supabase required. Discovers audiences from environment variables following the convention VIEWER_<NAME>_PASSWORD (bcrypt hash) and optionally VIEWER_<NAME>_COLOR. Editor logins use ADMIN_PASSWORD and EDITOR_PASSWORD (bcrypt hashes).

import { createPasswordAuth } from "@drawnagency/core/password";

This adapter has no database dependency and no OAuth support. It is useful for simple deployments or during initial setup before Supabase is configured.

Viewers authenticate with a username and password, not by picking an audience. Each row in viewer_credentials (site_id, audience_id, username, label, password_hash, password_cipher) grants exactly one audience’s access.

Authentication only. Authorization is unchanged and still keyed on the audience name — section/page access, chatbot audiences, and the editor’s preview menu never learn that credentials exist. Adding agency4 or removing agency2 cannot alter what the audience sees.

unique (site_id, username) is load-bearing: login is username-only, so a username must resolve to exactly one credential and therefore one audience. Usernames are lowercased and trimmed at the boundary (NormalizedUsernameSchema), so case can never block a login.

Capability, not configuration. audiences.credentials present ⇒ credential mode: login.astro renders a username field, /api/auth/verify-audience accepts { username, password }, and the settings UI shows sign-in lists. createPasswordAuth() omits it, so .env sites keep the audience dropdown and the audience-level verify path with no env sniffing anywhere — and they are now its only user.

Audience.credentialCount carries the number of sign-ins (0 in .env mode); hasPassword means “some way in exists” — a credential on Supabase, or the env-var password in .env mode — so the admin dashboard and MCP list_audiences needed no rework.

viewer_audiences.password_hash is gone (2026-08-05, 20260805154537). It was retained deliberately by the credentials migration so a client site on pre-credentials packages could still reach the broker’s audience-level verify; once every portal was redeployed that condition was met, and the column, the verify implementation, the broker action and the editor’s audience-password fields all came out together. The credentials migration had back-filled a credential per existing audience password, named after the audience slug, so those viewers keep working by typing the slug as their username.

On Supabase, an audience is now purely a label with an access list — a named sign-in is the only way to enter one. A fresh audience has no way in until a credential is added to it, which is what the settings UI says.

POST /api/auth/verify-audience returns ONE message — “Incorrect username or password” — for both an unknown username and a wrong password, and the adapter bcrypt-compares against a dummy hash when the username is missing so timing does not distinguish them either. Rate limiting is keyed on both the IP and u:<username>, since credential stuffing rotates IPs.

Revocation is eventually-consistent: the audience cookie is a 24h JWT with no rotation token, so deleting a credential stops new logins but leaves a live session working until it expires. That is the pre-existing posture documented in cookies.ts (a token_version claim is the fix, in the backlog); the settings UI says so where you delete.

A new site’s default audience is seeded by a DB trigger and carries no password, so the provisioner creates the first credential (client, label “Initial access”) with a generated passphrase and returns it in ProvisionResult.initialViewer for handover. It stores no sealed copy — sealing needs the primitives helper and apps/admin may runtime-import only @drawnagency/platform, so that would mean duplicating the crypto. The initial password therefore becomes revealable in-portal only after it is first changed.

The Audience Details page (/audiences) can show an existing sign-in’s password so a client can share access themselves. password_hash is bcrypt and one-way, so a second, reversible copy is stored in viewer_credentials.password_cipher whenever a credential is created or its password changed.

  • Key derivation — AES-256-GCM under a key derived from the site’s own SESSION_SECRET via HKDF-SHA256 (packages/primitives/src/lib/audience-secret.ts). No new env var, and nothing to provision. Format: v1.<base64url iv>.<base64url ciphertext>.
  • Where it is opened — always in the client site’s runtime. In platform mode the broker returns the blob and the hash; the platform holds no key and never sees plaintext.
  • The stale-seal guardcredentials.reveal() re-checks the opened password against that credential’s password_hash with bcrypt and returns null on mismatch. Any writer that updates the hash without the cipher (an older admin deploy, a manual SQL fix, a rotated SESSION_SECRET) therefore degrades to “not revealable” instead of showing a password that no longer works. Any new reveal() implementation must keep this check.
  • Capability, not config — presence of audiences.credentials is the capability. createPasswordAuth() omits it (env vars hold only bcrypt hashes), so /api/auth/viewer-password answers 501 and the UI masks the password.
  • Not a KMS — anyone holding the site’s SESSION_SECRET and the row can recover the password. That is the same secret that signs every session cookie, and the protected value is a shared viewing password.

Credentials created before this shipped — including every one back-filled from an audience password by the migration, and the provisioner’s initial sign-in — have no cipher and read as unavailable until their password is next changed. That is deliberate: bcrypt cannot be reversed, so there is nothing to back-fill.

POST /api/auth/viewer-password is gated to editors and the default audience — the client, who needs to hand a partner access without an editor login. The default-audience flag rides in the audience cookie as an isDefault claim (verifyAudienceClaims), so no per-render audience lookup is needed; a cookie issued before the claim existed reads as false and the viewer sees the page after their next login (≤24h, the cookie’s lifetime). Middleware carries it as locals.audienceIsDefault.

Consequence worth stating plainly: a default-audience session can read every sign-in password on the site, which makes a default-audience credential as sensitive as an editor login. Reveals are rate-limited and logged (actor role + audience name, never the plaintext).

Client sites wire up the adapter in portal.config.mjs:

import { defineConfig } from "@drawnagency/core/config";
import { supabaseAuth } from "@drawnagency/auth-supabase";
import { githubStorage } from "@drawnagency/github";
export default defineConfig({
auth: supabaseAuth(),
storage: githubStorage(),
site: { name: "My Brand Portal" },
});

defineConfig must be imported from @drawnagency/core/config — not the root @drawnagency/core. The root export includes the Astro integration, which imports node:url, node:fs, and other Node-only modules. portal.config.mjs is loaded in the browser during editor hydration (via the virtual:portal/config virtual module), so every import it touches must be browser-safe.

packages/core/src/middleware.ts is the single auth gate for all routes. It runs before every request via Astro’s middleware system. Decision logic:

  1. Public routes — pass through with no auth: /login, /edit/login, /edit/login/callback, /api/auth/sign-in, /api/auth/sign-out, /api/auth/verify-audience, /api/auth/oauth, /api/auth/reset-password, /api/auth/token-exchange, /api/webhooks/netlify.
  2. Media route (/api/media/*) — open to all tiers; session is resolved but not required (editors are identified so they can access draft-branch media).
  3. Editor routes (/edit and /edit/*, /api/*) — require a valid session. Unauthenticated requests to API routes get a 401 JSON response; unauthenticated page requests redirect to /edit/login?next=<url>.
  4. Owner-only API methods — a subset of API routes require role === "owner" for specific HTTP methods (e.g. POST/PATCH/DELETE on /api/auth/audiences, GET/POST/DELETE on /api/auth/users).
  5. Viewer routes — when the password gate is enabled, require a signed audience cookie (JWT signed with SESSION_SECRET). Forged or expired cookies are cleared and redirected to /login.

POST /api/auth/viewer-password has its own branch, alongside /api/chat and the document routes: the generic /api/* rule is editor-only and would 401 an audience-cookie viewer before the route could evaluate them. It is the only API path that reads the isDefault claim. Non-POST methods deliberately fall through to the editor-only branch.

Locals set by middleware:

  • locals.isEditor: boolean
  • locals.role: "owner" | "editor" | null
  • locals.userId: string | null
  • locals.audience: string | null
  • locals.audienceIsDefault: boolean — the audience cookie’s default-audience claim; gates /audiences and the reveal route
  • locals.viewerUsername: string | null — the sign-in a viewer used (credential mode only); label + audit only, never an access decision
  • locals.email: string | null — the editor’s email, for the sidebar’s “Logged in as” row

Page routes injected by the integration:

PatternPurpose
/[...slug]Viewer site — renders sections as server-side HTML
/loginViewer login (audience/password gate)
/audiencesAudience Details — auto-generated; editors and the default audience only (404 otherwise)
/editEditor shell
/edit/[...slug]Editor shell with section context
/edit/loginEditor login page (email or OAuth)
/edit/login/callbackOAuth PKCE callback
/edit/set-passwordSet/change editor password

API routes injected by the integration:

PatternPurpose
/api/saveSave section content to GitHub
/api/publishPublish a saved branch
/api/contentFetch current content
/api/auth/sign-inSign in (password or Supabase email)
/api/auth/sign-outSign out
/api/auth/oauthInitiate OAuth flow
/api/auth/verify-audienceExchange a sign-in (or, in .env mode, an audience password) for a signed cookie
/api/auth/audiencesCRUD for viewer audiences
/api/auth/credentialsCRUD for named viewer sign-ins (owner only)
/api/auth/viewer-passwordReveal an existing sign-in’s password (editors + default audience)
/api/auth/usersUser management (owner only)
/api/auth/password-enabledToggle password gate
/api/auth/set-passwordSet editor password
/api/auth/reset-passwordPassword reset email
/api/auth/token-exchangeSupabase PKCE token exchange
/api/media/[id]/[...path]Media serving with CDN caching
/api/historyContent history (commit list for the version-history navigator)
/api/history/changesChange summary for a commit range (added/edited/removed sections)
/api/build-statusNetlify build status
/api/webhooks/netlifyNetlify deploy webhook receiver