Skip to main content

Chapter 4: behind HTTP

WIP

Write 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 AuthError into a response with errorToHttp.

One request

Loading diagram...

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:

src/mailer.ts
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

src/server.ts
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=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mo stretchy=&quot;false&quot;>(</mo><mi>a</mi><mi>w</mi><msup><mi>k</mi><mo mathvariant=&quot;normal&quot; lspace=&quot;0em&quot; rspace=&quot;0em&quot;>′</mo></msup></mrow><annotation encoding=&quot;application/x-tex&quot;>(awk &amp;#x27;</annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:1.0019em;vertical-align:-0.25em;&quot;></span><span class=&quot;mopen&quot;>(</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02691em;&quot;>w</span><span class=&quot;mord&quot;><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03148em;&quot;>k</span><span class=&quot;msupsub&quot;><span class=&quot;vlist-t&quot;><span class=&quot;vlist-r&quot;><span class=&quot;vlist&quot; style=&quot;height:0.7519em;&quot;><span style=&quot;top:-3.063em;margin-right:0.05em;&quot;><span class=&quot;pstrut&quot; style=&quot;height:2.7em;&quot;></span><span class=&quot;sizing reset-size6 size3 mtight&quot;><span class=&quot;mord mtight&quot;><span class=&quot;mord mtight&quot;>′</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=&quot;katex&quot;><span class=&quot;katex-mathml&quot;><math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;><semantics><mrow><mo stretchy=&quot;false&quot;>(</mo><mi>a</mi><mi>w</mi><msup><mi>k</mi><mo mathvariant=&quot;normal&quot; lspace=&quot;0em&quot; rspace=&quot;0em&quot;>′</mo></msup></mrow><annotation encoding=&quot;application/x-tex&quot;>(awk &amp;#x27;</annotation></semantics></math></span><span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;><span class=&quot;base&quot;><span class=&quot;strut&quot; style=&quot;height:1.0019em;vertical-align:-0.25em;&quot;></span><span class=&quot;mopen&quot;>(</span><span class=&quot;mord mathnormal&quot;>a</span><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02691em;&quot;>w</span><span class=&quot;mord&quot;><span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.03148em;&quot;>k</span><span class=&quot;msupsub&quot;><span class=&quot;vlist-t&quot;><span class=&quot;vlist-r&quot;><span class=&quot;vlist&quot; style=&quot;height:0.7519em;&quot;><span style=&quot;top:-3.063em;margin-right:0.05em;&quot;><span class=&quot;pstrut&quot; style=&quot;height:2.7em;&quot;></span><span class=&quot;sizing reset-size6 size3 mtight&quot;><span class=&quot;mord mtight&quot;><span class=&quot;mord mtight&quot;>′</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

  1. Every route is yours. /auth/signin reads the body, runs flows.signIn with the caller's IP and User-Agent and the sid the request came in with, and applyIntents writes the answer as two Set-Cookie headers. /auth/session drops csrfHash, which is server-side state. duck-auth ships no routes, only the flows and this glue.
  2. Sign-up is yours too. What an account needs (a name, accepted terms, an invite) is your app's decision. assertStrength refuses a weak password as AUTH_INVALID_CREDENTIALS without naming the rule.
  3. The first note was refused. With a session cookie present, expressCsrf wants the duck-csrf cookie's value echoed in x-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.
  4. 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.
  5. Errors become responses in one place. Every route throws, and errorToHttp maps an AuthError to its status and { ok: false, error: { code, status } }. Anything else becomes a 500 that says nothing about the cause.

Try it

  1. Sign in with a wrong password. The answer is 401 AUTH_INVALID_CREDENTIALS, straight from errorToHttp.
  2. Sign up with the password short. It is refused with the same code, and no account is created.
  3. Run the reset: POST /auth/forgot-password with {"email":"ada@example.com"}, then POST /auth/reset-password with the printed token and a new password. The session in jar stops working.
  4. Call GET /auth/session with 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