InteractiveFrameworks

Context and guards

Protect the schema like any Nest API: the HTTP request that Nest's Apollo driver puts in every resolver's context, a guard and a parameter decorator that read it through GqlExecutionContext, Nest's exceptions as GraphQL errors, and formatError keeping an internal failure's details on the server.

What you'll learn

  • Find the HTTP request in the GraphQL context, where Nest's Apollo driver puts it as req, and extend the context with the context option
  • Turn Nest's ExecutionContext into a GqlExecutionContext in guards and custom decorators
  • Guard single queries and mutations with @UseGuards, and read the refusal as a FORBIDDEN error
  • Shape what errors tell clients with formatError, so that an internal failure's message stays on the server

Anyone can query the cats, but only staff may remove one, and the audit log must say who did. Over HTTP you would guard the route. In GraphQL there is one route, POST /graphql, carrying every query and every mutation, so guarding the route would guard everything or nothing. Protection moves to the resolvers: each query and mutation can have its own guard, pipes and interceptors, as each route had, and they need to reach the request behind the operation. There is a second concern too. GraphQL sends error messages to the client, and an internal failure's message can say far more than it should.

The request in the context

Every resolver runs with a context, one object per request that GraphQL hands to every resolver of that request. Nest's Apollo driver puts the HTTP request in it as req, so a resolver can read it with @Context('req'). The context option of forRoot() builds a richer object when you need one, ({ req, res }) => ({ req, res }) for instance, and the driver still adds req.

Guards and decorators, the GraphQL way

Guards, interceptors, filters and custom decorators receive Nest's ExecutionContext, as over HTTP. But its arguments are GraphQL's: parent, args, context and info, not request and response. context.switchToHttp().getRequest() finds nothing there. GqlExecutionContext.create(context) wraps it the GraphQL way, and getContext() returns the context object, req included:

@Injectable()
export class OpenHoursGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const { req } = GqlExecutionContext.create(context).getContext();
    if (!this.hours.isOpen(req.headers['x-shelter'])) throw new ForbiddenException('Closed today');
    return true;
  }
}

@UseGuards(OpenHoursGuard) on a @Query() or @Mutation() guards that one operation. GqlExecutionContext also has getArgs() for the operation's arguments. A custom parameter decorator works the same way: createParamDecorator((data, context) => GqlExecutionContext.create(context).getContext().req.headers['x-shelter']).

A guard that returns false gives Nest's generic Forbidden resource. Throwing a ForbiddenException with a message tells the client why. Either way the operation's field is null in data, and the error carries the code FORBIDDEN. Since the HTTP status stays 200, the code is what a client branches on.

What errors tell the client

Nest's HTTP exceptions become GraphQL errors with their message, their status in extensions, and a code Apollo maps from it (BAD_REQUEST, FORBIDDEN, UNAUTHENTICATED). Any other error keeps its own message and gets the code INTERNAL_SERVER_ERROR. That message is written for developers: a database driver's connect ECONNREFUSED vet-db.internal:5432 (user shelter_admin) hands an attacker a host name and a user name. Apollo in production hides the stack trace, but not the message.

formatError is the last step before an error leaves the server. It receives each formatted error and returns what the client gets. The rule here: an error without a status is one Nest did not describe on purpose, so replace it. The original still reaches the console, where Nest logs it.

formatError: (formatted) =>
  formatted.extensions?.code === 'INTERNAL_SERVER_ERROR' && formatted.extensions.status === undefined
    ? { message: 'Something went wrong', path: formatted.path, extensions: { code: 'INTERNAL_SERVER_ERROR' } }
    : formatted,

Your task

Anyone may list cats. removeCat and auditLog are for staff, who send the header x-staff-key: whiskers and their name in x-staff-name. CatsService.remove(id, staff) writes <cat> removed by <staff> to the audit log. vetRecords fails today, because the vet's database is down.

  1. Write StaffGuard: an operation passes when the request's x-staff-key is whiskers, and is otherwise refused with a ForbiddenException saying Staff only.
  2. Write @CurrentStaff(), which gives a resolver the request's x-staff-name, or guest.
  3. In CatsResolver, guard removeCat and auditLog. whoami answers the current staff name, and removeCat records it.
  4. In forRoot(), a formatError: an error coded INTERNAL_SERVER_ERROR with no status reaches the client as { message: 'Internal server error', path, extensions: { code: 'INTERNAL_SERVER_ERROR' } }. Every other error passes unchanged.

When it fails

  • Every guarded operation fails with Cannot read properties of undefined (reading 'headers') in the console: the guard used switchToHttp().getRequest(). Wrap the context in GqlExecutionContext.
  • Forbidden resource instead of Staff only: the guard returned false. Throw the exception with the message you want.
  • vetRecords shows the database's host to the client: no formatError, or its condition does not match.
  • Cat 9 not found became Internal server error: the mask applies to errors with a status too. Keep Nest's HTTP exceptions as they are.

Remember

  • One endpoint, many operations: guard each query or mutation, not the route.
  • Nest's Apollo driver puts the request in the context as req; context adds more.
  • GqlExecutionContext.create(context).getContext() reaches it from guards and decorators.
  • formatError decides what errors say; keep internal messages on the server.
Stuck? Show a hint

app.module.ts: formatError: (formatted) => a masked { message, path, extensions: { code } } when extensions.code is INTERNAL_SERVER_ERROR and extensions.status is undefined, else formatted. staff.guard.ts: GqlExecutionContext.create(context).getContext().req.headers, throw new ForbiddenException('Staff only'). current-staff.decorator.ts: createParamDecorator((data, context) => ... ?? 'guest'). The resolver: @UseGuards(StaffGuard) on removeCat and auditLog, @CurrentStaff() where the name is needed.