Skip to main content

NestJS

WIP

Writing the auth controller over auth.flows, the guards and param decorators for your own routes, the actor middleware, and gating sign-in on your own rules

On @nestjs/platform-express, Express's applyIntents writes a flow's answer onto Nest's response.

csrf.guard.ts
import { type CanActivate, type ExecutionContext, Injectable } from '@nestjs/common'
import { makeCsrfGuard } from '@gentleduck/auth/server/nestjs'
import { auth } from './auth'

@Injectable()
export class CsrfGuard implements CanActivate {
  private readonly guard = makeCsrfGuard(auth)

  canActivate(ctx: ExecutionContext): Promise<boolean> {
    return this.guard.canActivate(ctx)
  }
}
auth.controller.ts
import { Body, Controller, Get, Post, Req, Res, UseGuards } from '@nestjs/common'
import { AuthError } from '@gentleduck/auth/core'
import { applyIntents, type ExpressAdapter } from '@gentleduck/auth/server/express'
import { nodeHeadersToFetch } from '@gentleduck/auth/server/generic'
import { type NestAdapter, nestCaller } from '@gentleduck/auth/server/nestjs'
import { auth } from './auth'
import { CsrfGuard } from './csrf.guard'

@Controller('auth')
@UseGuards(CsrfGuard)
export class AuthController {
  @Post('signin')
  async signIn(@Req() req: NestAdapter.Request, @Body() body: unknown, @Res() res: ExpressAdapter.Response) {
    if (typeof body !== 'object' || body === null) throw new AuthError('AUTH_INVALID_PARAMETERS')
    const providerId: unknown = Reflect.get(body, 'providerId')
    if (typeof providerId !== 'string') throw new AuthError('AUTH_INVALID_PARAMETERS')
    const headers = nodeHeadersToFetch(req.headers)
    const { intents } = await auth.flows.signIn({
      input: Reflect.get(body, 'input') ?? {},
      providerId,
      ...nestCaller(req),
      previousSid: auth.transport.extract({ headers }) ?? undefined,
    })
    applyIntents(intents, res)
  }

  @Post('signout')
  async signOut(@Req() req: NestAdapter.Request, @Res() res: ExpressAdapter.Response) {
    const sid = auth.transport.extract({ headers: nodeHeadersToFetch(req.headers) })
    applyIntents(sid ? (await auth.flows.signOut(sid)).intents : auth.transport.revoke(), res)
  }

  @Get('session')
  async session(@Req() req: NestAdapter.Request, @Res() res: ExpressAdapter.Response) {
    const resolved = await auth.resolveSession({ headers: nodeHeadersToFetch(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)
  }
}
  • Register NestExceptionFilter so a thrown AuthError answers at its status: app.useGlobalFilters(new NestExceptionFilter()).
  • The IP the session records is req.ip, so set Express's trust proxy through app.set('trust proxy', 1) on the Nest app.
  • The provider routes are on the OAuth page. Leave the callback out of CsrfGuard, and let Nest read Apple's form post as text: create the app with { bodyParser: false }, then app.useBodyParser('json') and app.useBodyParser('text', { type: 'application/x-www-form-urlencoded' }).

Gating sign-in

Your route sees the new session before the browser does. Check it before applying the intents, and revoke it on a refusal, so the client never holds it:

const { session, sid, intents } = await auth.flows.signIn({ /* as above */ })
if (session && (await isSuspended(session.identityId))) {
  await auth.flows.signOut(sid)
  throw new ForbiddenException('contact support')
}
applyIntents(intents, res)

It runs after the password is checked, so a refusal tells nothing to someone who does not know it.

Your own routes

import { Controller, type MiddlewareConsumer, Module, type NestModule, Post, UseGuards } from '@nestjs/common'
import type { Identities } from '@gentleduck/auth/core'
import { CurrentIdentity, makeGuard, nestActorContext } from '@gentleduck/auth/server/nestjs'

@Controller('api')
@UseGuards(makeGuard(auth))
class ProfileController {
  @Post('profile')
  update(@CurrentIdentity() identity: Identities.Me | null) {
    return updateProfile(identity)
  }
}

@Module({ controllers: [AuthController, ProfileController] })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(nestActorContext(auth)).forRoutes('*')
  }
}
PieceDoes
makeGuard(auth)Refuses a request with no session with AUTH_UNAUTHENTICATED, and checks CSRF. { required: false } lets anonymous requests through; { csrf: false } skips the check.
makeCsrfGuard(auth)The CSRF check alone.
@CurrentIdentity(), @CurrentSession()What the guard or the middleware resolved.
nestActorContext(auth)Binds the actor for everything downstream. The guard reuses the session it resolved, so the pair reads it once.
NestExceptionFilterAnswers an AuthError at its status, with Cache-Control: no-store.
nestCaller(req)The IP and User-Agent to pass to a flow.