NestJS
WIPWriting 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
NestExceptionFilterso a thrownAuthErroranswers at its status:app.useGlobalFilters(new NestExceptionFilter()). - The IP the session records is
req.ip, so set Express'strust proxythroughapp.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 }, thenapp.useBodyParser('json')andapp.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('*')
}
}
| Piece | Does |
|---|---|
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. |
NestExceptionFilter | Answers an AuthError at its status, with Cache-Control: no-store. |
nestCaller(req) | The IP and User-Agent to pass to a flow. |