Authdog

Handbook

Last updated Oct 7, 2026
View as Markdown

The handbook is an Astro site with a public home page and a member route. Public copy stays on /. Member chapters stay on /handbook and render only after getUser accepts the session.

Clone starters/astro. SDK reference: Astro.

What we'll build

Two screens, and a hard line between them.

/ is the public handbook. It does not read the session. The title is Handbook. The body says this site proves identity only: a signed-in session is not a permission grant. When PUBLIC_AUTHDOG_PUBLIC_KEY is set, the page decodes that key — drop the pk_ prefix, base64-decode the JSON — and builds a sign-in link from identityHost and environmentId. That link opens the hosted Account portal. A second link points at /handbook. When the key is missing, the page names the env var instead of offering a dead link.

The same page loads initAuthdog() from @authdog/astro/client. After the Account portal returns with ?token=, that script checks the token shape, stores it in localStorage, strips it from the URL, and reloads. The bootstrap does not write authdog-session. That cookie is HttpOnly. A server that has already validated the token has to set it.

/handbook is the member route. Before any chapter HTML is sent, the page calls createAuthdogServer().getUser(Astro.request). getUser sends the cookie to Authdog userinfo. A missing cookie, a rejected token, or a failed call resolves to null, and the page returns Astro.redirect("/"). An accepted profile renders two chapters:

  • On-call rotation — who holds the pager this week.
  • Incident comms — what to post in the first 15 minutes.

The list is the same for every signed-in reader. It answers who the reader is. It does not decide what they may do. Apply authorization after getUser.

Five files draw that line.

astro.config.mjs sets output: "server" and the Node standalone adapter. Frontmatter that calls userinfo never runs in a static build.

src/middleware.ts wraps every request with authdogMiddleware. It copies the raw authdog-session cookie onto Astro.locals.authdog.session and sets isAuthenticated when that cookie is non-empty. / stays reachable, including when the cookie is stale. src/env.d.ts types App.Locals.authdog so the rest of the app can read that object.

src/pages/handbook.astro ignores the middleware boolean and uses getUser as the gate. The chapter list is the first markup after that check.

src/pages/api/logout.ts exposes GET /api/logout. authdog.logout clears the cookie and redirects. The next visit to /handbook fails getUser and returns home.

What you need

Requirement Detail
Public key pk_... from the console
Environment Copy .env.example to .env and set PUBLIC_AUTHDOG_PUBLIC_KEY
Secret key Never put sk_... in client code
Return URL Account portal back to a member route. Local: http://localhost:4321/handbook
Runtime Astro ^5 or ^6, with SSR. @authdog/astro/server needs output: "server" or "hybrid"

Run

npm install
npm run dev

Open the URL Astro prints, usually http://localhost:4321.

With no session, / shows the handbook title, the identity note, and the sign-in link. /handbook redirects home. After sign-in, /handbook lists On-call rotation and Incident comms. Sign out from /api/logout, then open /handbook again: the redirect returns.

Server

getUser runs in page frontmatter, against the incoming request. A static build never sees that request.

astro.config.mjs
import { defineConfig } from "astro/config"
import node from "@astrojs/node"

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
})

Middleware

authdogMiddleware runs on every request and populates Astro.locals.authdog. isAuthenticated means the authdog-session cookie exists. It does not validate the token. Public pages stay reachable.

src/middleware.ts
import { defineMiddleware } from "astro:middleware"
import { authdogMiddleware } from "@authdog/astro/server"

export const onRequest = defineMiddleware(
  authdogMiddleware({
    publicKey: import.meta.env.PUBLIC_AUTHDOG_PUBLIC_KEY ?? "",
  }),
)

Declare the locals type so Astro.locals.authdog type-checks:

src/env.d.ts
declare namespace App {
  interface Locals {
    authdog: import("@authdog/astro/server").AuthdogLocals
  }
}

Public home

The home page never calls getUser. The sign-in URL comes from the public key. initAuthdog() only handles the ?token= return in the browser.

src/pages/index.astro
---
const publicKey = import.meta.env.PUBLIC_AUTHDOG_PUBLIC_KEY ?? ""
let signinUri: string | null = null
if (publicKey) {
  try {
    const payload = JSON.parse(
      Buffer.from(publicKey.replace("pk_", ""), "base64").toString(),
    )
    signinUri = `${payload.identityHost}/signin/${payload.environmentId}`
  } catch {
    signinUri = null
  }
}
---

<h1>Handbook</h1>
<p>
  This site proves identity only. A signed-in session is not a permission
  grant.
</p>
{
  signinUri ? (
    <p>
      <a href={signinUri}>Sign in</a>
      {" · "}
      <a href="/handbook">Member chapters</a>
    </p>
  ) : (
    <p>
      Set <code>PUBLIC_AUTHDOG_PUBLIC_KEY</code> in <code>.env</code>.
    </p>
  )
}

<script>
  import { initAuthdog } from "@authdog/astro/client"
  initAuthdog()
</script>

Member chapters

getUser validates the cookie through userinfo. The redirect happens in frontmatter, so an anonymous reader never receives the chapter list.

src/pages/handbook.astro
---
import { createAuthdogServer } from "@authdog/astro/server"

const CHAPTERS = [
  { title: "On-call rotation", summary: "Who holds the pager this week." },
  { title: "Incident comms", summary: "What to post in the first 15 minutes." },
]

const authdog = createAuthdogServer({
  publicKey: import.meta.env.PUBLIC_AUTHDOG_PUBLIC_KEY ?? "",
})

const profile = await authdog.getUser(Astro.request).catch(() => null)
if (!profile) {
  return Astro.redirect("/")
}
---

<ul>
  {CHAPTERS.map((chapter) => (
    <li>
      {chapter.title}. {chapter.summary}
    </li>
  ))}
</ul>

Sign out

GET /api/logout clears authdog-session and redirects. The following request to /handbook fails getUser and returns home.

src/pages/api/logout.ts
import type { APIRoute } from "astro"
import { createAuthdogServer } from "@authdog/astro/server"

const authdog = createAuthdogServer({
  publicKey: import.meta.env.PUBLIC_AUTHDOG_PUBLIC_KEY ?? "",
})

export const GET: APIRoute = ({ request }) => authdog.logout(request)