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 devOpen 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.
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.
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:
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.
---
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.
---
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.
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)