Skip to main content

Server adapters

WIP

The auth routes your app writes over auth.flows, what each must check, and the per-framework glue for them, such as the CSRF guard, the caller fingerprint, the intents writer and the actor scope

duck-auth ships no routes and no client. Your app writes its own routes over auth.flows.* and calls them with its own fetch. Each adapter carries only the glue those routes need for one framework. None imports its framework: each is typed against the few methods it calls.

The routes you write

RouteCalls
POST /auth/signinauth.flows.signIn({ providerId, input, previousSid, ...caller })
POST /auth/signoutauth.flows.signOut(sid), or auth.transport.revoke() when the request holds no session
GET /auth/sessionauth.resolveSession({ headers }).orNull()
POST /auth/providers/:id/beginauth.flows.beginProvider(id, input): the OAuth redirect, the magic-link email, the passkey challenge
GET and POST /auth/providers/:id/callbackauth.flows.signIn with the IdP's code and state

The paths are yours to choose. Write only what you use; a password-only app needs the first three. examples/duck-auth has the full set for every framework below.

Adapters

FrameworkImportCSRFCallerWrites intentsActor scope
Expressserver/expressexpressCsrfexpressCallerapplyIntentsexpressActorContext
Fastifyserver/fastifyfastifyCsrffastifyCallerexecuteIntentsfastifyWithActor
Honoserver/honohonoCsrfhonoCallerexecuteIntentshonoActorContext
Koaserver/koakoaCsrfkoaCallerkoaApplyIntentskoaActorContext
Elysiaserver/elysiaelysiaCsrfelysiaCallerexecuteIntentselysiaWithActor
Next.jsserver/nextwithNextCsrfnextCallerexecuteIntentsnextWithActor
NestJSserver/nestjsmakeCsrfGuard, makeGuardnestCallerapplyIntentsnestActorContext
gRPCserver/grpcwithGrpc
Any Fetch runtimeserver/genericcsrfGuard from corecallerContextexecuteIntents

Every import is under @gentleduck/auth/. executeIntents is in server/generic, and NestJS on platform-express takes Express's applyIntents.

What each route must do

  • Guard CSRF on sign-in, sign-out, begin and every other mutation. The guard answers 403 AUTH_CSRF: a cross-site Sec-Fetch-Site is refused, and once a session exists, the x-csrf-token header must match its CSRF cookie. A bearer request with no cookie skips the token.
  • Pass the caller from the adapter's caller helper, so the session records the User-Agent and the IP the framework resolved, never a forwarded header. Set your framework's proxy trust so that IP is the client's.
  • Pass previousSid, the session the request came in with, so signing in again revokes it instead of leaving two.
  • Strip csrfHash from any session you answer with. It is server-side state; the browser holds the plaintext.
  • Answer Cache-Control: no-store. The intents writers and jsonResponse set it.
  • Map a throw with errorToHttp, which answers an AuthError as { ok: false, error: { code, status, detail? } } at its status, and anything else as a 500 with the cause logged and withheld.
  • Answer begin to a script as { url } when it redirects, checked with isSafeRedirectUrl. A script cannot follow a redirect to the IdP.

The OAuth callback is the exception to CSRF: Apple posts it from its own page, and the signed state bound to the pre-auth cookie is the proof instead. Keep it to oauth providers, so it signs nobody in any other way. See OAuth.

Your own routes

For every other route:

  • Read the session with auth.resolveSession({ headers }). .orNull() answers null when there is none, and still throws when the store is down.
  • Guard it against CSRF with the adapter's CSRF helper, on every state-changing route a cookie session reaches.
  • Bind the actor with the adapter's actor scope, so writes downstream record who made them in created_by and updated_by. Give it getCaller, and it also runs hijack detection and the anomaly detectors on every request.

The actor scope authorises nothing: it binds nobody for a request with no session, and refusing that is your guard's job.