Skip to main content

Express

WIP

Writing the auth routes on Express 4 or 5 over auth.flows, guarding your own routes, and binding the actor

import { AuthError } from '@gentleduck/auth/core'
import {
  applyIntents,
  expressActorContext,
  expressCaller,
  expressCsrf,
  toHeaders,
} from '@gentleduck/auth/server/express'
import { errorToHttp } from '@gentleduck/auth/server/generic'
import express from 'express'

const app = express()
app.set('trust proxy', 1)
app.use(express.json())
app.use('/auth', 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)
  // `csrfHash` is server-side state; the browser holds the plaintext
  const { csrfHash: _csrfHash, ...session } = resolved.session
  applyIntents([{ type: 'json', status: 200, body: { session, identity: resolved.identity } }], res)
})

app.use((err, _req, res, _next) => {
  const { status, body } = errorToHttp(err)
  res.status(status).json(body)
})
  • Express 5 hands an async throw to the error handler. On Express 4, catch it in each route and call next(err).
  • trust proxy decides which IP req.ip answers, and so what the session records. Set it to the number of proxies in front of you.
  • applyIntents(intents, res) writes a flow's answer, cookies included, with Cache-Control: no-store.
  • The provider routes are on the OAuth page. Mount the callback before expressCsrf, and parse its form body with express.text({ type: 'application/x-www-form-urlencoded' }).

Your own routes

app.use('/api', expressCsrf(auth), expressActorContext(auth))

app.get('/api/me', async (req, res) => {
  const resolved = await auth.resolveSession({ headers: toHeaders(req.headers) }).orNull()
  if (!resolved) {
    res.status(401).end()
    return
  }
  res.json(resolved.identity)
})
  • expressCsrf answers the 403 itself, so it does not depend on your error handler.
  • expressActorContext passes a refusal to next(err). Its AuthError carries status and toJSON() for your error handler.