Skip to main content

express

Express middleware, per-route guard, and admin router for duck-iam. No runtime dependency on Express.

Install

import { accessMiddleware, guard, adminRouter } from '@gentleduck/iam/server/express'

No runtime dependency on Express - the integration is typed against minimal req/res/next interfaces and works with Express 4, Express 5, and any compatible router.


Global middleware

Apply access control to every route under a path prefix.

import { accessMiddleware } from '@gentleduck/iam/server/express'
import { IAM_METHOD_ACTION_MAP } from '@gentleduck/iam/server/generic'

const middleware = accessMiddleware(engine, {
  // Extract user ID from your auth layer (passport, jwt, etc.)
  getUserId: (req) => req.user?.id,

  // Map HTTP method to action (defaults to IAM_METHOD_ACTION_MAP)
  getAction: (req) => IAM_METHOD_ACTION_MAP[req.method],

  // Infer resource from the URL path
  getResource: (req) => {
    const parts = req.path.split('/').filter(Boolean)
    return { type: parts[0] ?? 'root', id: parts[1], attributes: {} }
  },

  // Optional: extract scope from headers or query
  getScope: (req) => req.headers['x-org-id'] as string | undefined,

  // Optional: extract environment context (IP, user agent, timestamp)
  getEnvironment: (req) => ({
    ip: req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: Date.now(),
  }),

  // Custom denial response
  onDenied: (req, res) => res.status(403).json({ error: 'Forbidden' }),

  // Custom error handler
  onError: (err, req, res) => res.status(500).json({ error: 'Internal server error' }),
})

app.use('/api', middleware)

Per-route guard

Use guard on individual routes when the action and resource are known at definition time.

import { guard } from '@gentleduck/iam/server/express'

// Basic guard - action and resource are fixed
app.delete('/posts/:id', guard(engine, 'delete', 'post'), (req, res) => {
  // Only reached if engine.can() returns true
  res.json({ deleted: true })
})

// Scoped guard - restrict to a specific scope
app.post('/admin/users', guard(engine, 'manage', 'user', { scope: 'admin' }), (req, res) => {
  res.json({ created: true })
})

// With custom environment
app.patch(
  '/posts/:id',
  guard(engine, 'update', 'post', {
    getEnvironment: (req) => ({ ip: req.ip, timestamp: Date.now() }),
  }),
  handler,
)

// Custom user ID extraction
app.get(
  '/reports',
  guard(engine, 'read', 'report', {
    getUserId: (req) => req.headers['x-api-key'] as string,
  }),
  handler,
)

Error handling differs between the two:

  • guard catches errors and passes them to next(err) - they reach Express's error-handling middleware.
  • accessMiddleware catches errors and calls the onError option - defaults to a 500 JSON response.

Admin router

Mount a pre-built admin API for managing policies, roles, and assignments. The authorize callback is required - the factory throws at boot if it's missing.

import express from 'express'
import { adminRouter } from '@gentleduck/iam/server/express'

const createRouter = adminRouter(engine, {
  authorize: (req) => req.user?.role === 'platform-admin',
})
app.use('/api/access-admin', createRouter(() => express.Router()))

Every endpoint (read + write) runs authorize(req) before dispatching. Falsy returns produce 401 Unauthorized; thrown errors route through onError (default 500).

CSRF protection (default-on)

Mutation handlers (PUT/POST/DELETE) run a Sec-Fetch-Site check by default (SEC-103 / CAVEAT-2). Browsers populate the header automatically; cross-site form posts are rejected with 403, same-site / same-origin requests pass. Non-browser callers (no header) pass - they must be gated by bearer / mTLS.

// Default - cookie-auth admin UIs get CSRF protection with no opt-in.
adminRouter(engine, { authorize })

// Server-to-server bearer/mTLS API - disable the check entirely.
adminRouter(engine, { authorize, csrfCheck: false })

// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
adminRouter(engine, {
  authorize,
  csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers.origin as string),
})

This exposes:

MethodPathDescription
GET/policiesList all policies
GET/rolesList all roles
PUT/policiesCreate or update a policy
PUT/rolesCreate or update a role
POST/subjects/:id/rolesAssign a role to a subject
DELETE/subjects/:id/roles/:roleIdRevoke a role from a subject

adminRouter options:

OptionTypeDefaultDescription
authorize(req) -> boolean | Promise<boolean>requiredPer-request guard. Mounting unauthenticated is impossible.
csrfCheck((req) -> boolean) | falsebuilt-in Sec-Fetch-Site (SEC-103)CSRF guard for mutations; false to disable
onUnauthorized(req, res) -> void401 JSONCustom unauthorized response
onError(err, req, res) -> void500 JSONCustom error handler

Options reference

OptionTypeDefaultDescription
getUserId(req) -> string or nullreq.user?.idExtract the subject ID from the request
getAction(req) -> stringHTTP method mapMap the request to an action
getResource(req) -> ResourceInfer from URL pathMap the request to a resource
getEnvironment(req) -> EnvironmentIP + user agent + timestampExtract environment context
getScope(req) -> string or undefinedundefinedExtract scope (e.g. org ID, team ID)
onDenied(req, res) -> void403 JSONCustom denial response
onError(err, req, res, next) -> void500 JSONCustom error handler

The full options object conforms to Express.IOptions. The admin router options conform to Express.IAdminRouterOptions, and the required authorize callback signature is Express.IAdminAuthorize.


Types

All types live under the Express namespace at @gentleduck/iam/server/express. Type-only - zero bundle cost.

  • Express.IOptions - options for accessMiddleware and guard (identity, action, resource, scope, environment, and failure handlers).
  • Express.IAdminAuthorize - signature of the required authorize callback for adminRouter.
  • Express.IAdminRouterOptions - options for adminRouter (authorize, onUnauthorized, onError).
import type { Express } from '@gentleduck/iam/server/express'

const opts: Express.IOptions = {
  getUserId: (req) => req.user?.id,
  getScope: (req) => req.headers['x-org-id'] as string | undefined,
}

const adminAuth: Express.IAdminAuthorize = (req) => req.user?.role === 'platform-admin'

Deprecated bare aliases (IExpressOptions, IAdminAuthorize, IAdminRouterOptions) remain for back-compat and will be removed in 3.0.