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 a viewer password
  • 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.

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.

Locals set by middleware:

  • locals.isEditor: boolean
  • locals.role: "owner" | "editor" | null
  • locals.userId: string | null
  • locals.audience: string | null

Page routes injected by the integration:

PatternPurpose
/[...slug]Viewer site — renders sections as server-side HTML
/loginViewer login (audience/password gate)
/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 audience password for signed cookie
/api/auth/audiencesCRUD for viewer audiences
/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
/api/build-statusNetlify build status
/api/webhooks/netlifyNetlify deploy webhook receiver