Chapter 4: behind HTTP
WIPWrite the auth routes on Express over auth.flows, add sign-up and recovery routes, guard the notes API with the session and CSRF, and map errors to responses
Until now the engine answered a script. This chapter puts it behind Express, where the session travels as a cookie and every response is built from the engine's intents.
Learning goals
- Write the sign-in, sign-out and session routes over
auth.flows. - Add sign-up, verification and reset the same way.
- Guard your own routes with the session and the CSRF check.
- Turn any thrown
AuthErrorinto a response witherrorToHttp.
One request
The engine never touches Express's response. It answers intents (set this cookie, redirect there, send this JSON), and the Express adapter applies them.
Step by step
Install Express
npm i express@5
npm i -D @types/express
The routes throw from async handlers, which Express 5 hands to the error handler. Express 4 leaves those throws unhandled.
Print the links
The server has no outbox to read, so the mailer prints each link where you can copy it:
import type { Deliver } from '@gentleduck/auth/core'
export const deliver: Deliver = async ({ kind, identity, vars }) => {
console.log(`[${kind}] ${identity.profile.email}: ${vars.url}`)
}
src/auth.ts stays as it was at the end of chapter 2, without the listeners.
The server
import { AuthError, orNull } from '@gentleduck/auth/core'
import { applyIntents, expressCaller, expressCsrf, toHeaders } from '@gentleduck/auth/server/express'
import { errorToHttp } from '@gentleduck/auth/server/generic'
import express, { type ErrorRequestHandler } from 'express'
import { auth } from './auth'
const notes = new Map<string, string[]>()
const app = express()
app.use(express.json())
app.use(expressCsrf(auth))
app.post('/auth/signin', async (req, res) => {
const { providerId, input } = req.body ?? {}
if (typeof providerId !== 'string') throw new AuthError('AUTH_INVALID_PARAMETERS')
const headers = toHeaders(req.headers)
const { intents } = await auth.flows.signIn({
input: input ?? {},
providerId,
...expressCaller(req),
previousSid: auth.transport.extract({ headers }) ?? undefined,
})
applyIntents(intents, res)
})
app.post('/auth/signout', async (req, res) => {
const sid = auth.transport.extract({ headers: toHeaders(req.headers) })
applyIntents(sid ? (await auth.flows.signOut(sid)).intents : auth.transport.revoke(), res)
})
app.get('/auth/session', async (req, res) => {
const resolved = await auth.resolveSession({ headers: toHeaders(req.headers) }).orNull()
if (!resolved) return applyIntents([{ type: 'json', status: 200, body: { session: null, identity: null } }], res)
const { csrfHash: _csrfHash, ...session } = resolved.session
applyIntents([{ type: 'json', status: 200, body: { session, identity: resolved.identity } }], res)
})
app.post('/auth/signup', async (req, res) => {
const { email, password } = req.body
auth.passwords.assertStrength(password)
const identity = await auth.identities.create({ profile: { email, username: email } })
await auth.passwords.set(identity.id, password, auth.cfg.stores.credentials)
await auth.flows.requestEmailVerification({ identityId: identity.id })
res.status(201).json({ identityId: identity.id })
})
app.post('/auth/verify-email', async (req, res) => {
await auth.flows.completeEmailVerification({ token: String(req.body.token) })
res.status(204).end()
})
app.post('/auth/forgot-password', async (req, res) => {
await auth.flows.requestPasswordReset({
input: { email: String(req.body.email) },
findIdentityByEmail: (email) => orNull(auth.identities.getByEmail(email)),
})
res.status(202).end()
})
app.post('/auth/reset-password', async (req, res) => {
await auth.flows.completePasswordReset({ token: String(req.body.token), newPassword: String(req.body.password) })
res.status(204).end()
})
app.use('/api', async (req, res, next) => {
const resolved = await auth.resolveSession({ headers: toHeaders(req.headers) }).orNull()
if (!resolved?.identity) return res.status(401).end()
res.locals.session = resolved.session
res.locals.identity = resolved.identity
next()
})
app.get('/api/notes', (_req, res) => {
res.json(notes.get(res.locals.identity.id) ?? [])
})
app.post('/api/notes', (req, res) => {
const mine = notes.get(res.locals.identity.id) ?? []
notes.set(res.locals.identity.id, [...mine, String(req.body.text)])
res.status(201).json(mine.length + 1)
})
const onError: ErrorRequestHandler = (err, _req, res, _next) => {
const { status, body } = errorToHttp(err)
res.status(status).json(body)
}
app.use(onError)
app.listen(3000, () => console.log('DuckNotes on http://localhost:3000'))
Drive it with curl
npx tsx src/server.ts
npx tsx src/server.ts
In a second terminal, sign up, then verify with the token the server printed:
curl -s localhost:3000/auth/signup -H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"correct-horse-battery"}'
curl -s localhost:3000/auth/verify-email -H 'content-type: application/json' \
-d '{"token":"<token from the printed link>"}'
Sign in, keeping the cookies in jar, and write a note:
curl -s -c jar localhost:3000/auth/signin -H 'content-type: application/json' \
-d '{"providerId":"password","input":{"email":"ada@example.com","password":"correct-horse-battery"}}'
curl -s -b jar localhost:3000/api/notes -H 'content-type: application/json' -d '{"text":"buy bread"}'
# {"ok":false,"error":{"code":"AUTH_CSRF","status":403}}
curl -s -b jar localhost:3000/api/notes -H 'content-type: application/json' \
-H "x-csrf-token: <MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">(</mo><mi>a</mi><mi>w</mi><msup><mi>k</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msup></mrow><annotation encoding="application/x-tex">(awk &#x27;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.0019em;vertical-align:-0.25em;"></span><span class="mopen">(</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02691em;">w</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7519em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span></span></span></span></span></span></span></span>"/>6 == "duck-csrf" { print $7 }' jar)" -d '{"text":"buy bread"}'
# 1
curl -s -b jar localhost:3000/api/notes
# ["buy bread"]
Sign out, and the notes are closed again:
curl -s -b jar -c jar localhost:3000/auth/signout -X POST \
-H "x-csrf-token: <MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">(</mo><mi>a</mi><mi>w</mi><msup><mi>k</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msup></mrow><annotation encoding="application/x-tex">(awk &#x27;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.0019em;vertical-align:-0.25em;"></span><span class="mopen">(</span><span class="mord mathnormal">a</span><span class="mord mathnormal" style="margin-right:0.02691em;">w</span><span class="mord"><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7519em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span></span></span></span></span></span></span></span>"/>6 == "duck-csrf" { print $7 }' jar)"
curl -s -o /dev/null -w '%{http_code}\n' -b jar localhost:3000/api/notes
# 401
What just happened
- Every route is yours.
/auth/signinreads the body, runsflows.signInwith the caller's IP and User-Agent and the sid the request came in with, andapplyIntentswrites the answer as twoSet-Cookieheaders./auth/sessiondropscsrfHash, which is server-side state. duck-auth ships no routes, only the flows and this glue. - Sign-up is yours too. What an account needs (a name, accepted terms, an invite) is your app's decision.
assertStrengthrefuses a weak password asAUTH_INVALID_CREDENTIALSwithout naming the rule. - The first note was refused. With a session cookie present,
expressCsrfwants theduck-csrfcookie's value echoed inx-csrf-token. A page on another site can make the browser send the cookie, but cannot read it to echo. A request with no session gets only the origin checks, which is why sign-up and verification passed without a token. - The guard is one middleware. It resolves the session once and hands the session and identity on in
res.locals..orNull()makes a missing session a 401, while a store outage still reaches the error handler. - Errors become responses in one place. Every route throws, and
errorToHttpmaps anAuthErrorto its status and{ ok: false, error: { code, status } }. Anything else becomes a 500 that says nothing about the cause.
Try it
- Sign in with a wrong password. The answer is 401
AUTH_INVALID_CREDENTIALS, straight fromerrorToHttp. - Sign up with the password
short. It is refused with the same code, and no account is created. - Run the reset:
POST /auth/forgot-passwordwith{"email":"ada@example.com"}, thenPOST /auth/reset-passwordwith the printed token and a newpassword. The session injarstops working. - Call
GET /auth/sessionwith the jar before and after signing out.
Where we are
ducknotes/
src/
mailer.ts - prints each link
auth.ts - the engine
server.ts - Express: auth routes, your routes, the notes API
See also
- Express: the routes,
applyIntentsand the actor context. - Server adapters: the same pieces for Hono, Next.js, Fastify and the rest.
- Errors: every code and its status.
- Chapter 5: in the browser