nestjs
NestJS guard, @Authorize decorator, type-safe decorator factory, and engine provider for duck-iam.
Install
import {
nestAccessGuard,
Authorize,
createTypedAuthorize,
createEngineProvider,
IAM_ACCESS_ENGINE_TOKEN,
IAM_ACCESS_METADATA_KEY,
} from '@gentleduck/iam/server/nest'
Zero hard dependency on @nestjs/common - uses minimal duck-typed interfaces. reflect-metadata is used when available, with a direct property fallback so the decorator works without it.
Guard factory
Create a NestJS-compatible guard function. It reads metadata from the @Authorize decorator on each handler.
import { nestAccessGuard } from '@gentleduck/iam/server/nest'
const canAccess = nestAccessGuard(engine, {
getUserId: (req) => req.user?.id ?? req.user?.sub,
getScope: (req) => req.headers['x-org-id'] as string | undefined,
getResourceId: (req) => req.params?.id,
onError: (err, req) => false,
})
// Register as a global guard in your module:
// APP_GUARD -> { provide: APP_GUARD, useValue: { canActivate: canAccess } }
Handlers without an @Authorize decorator are allowed by default. The guard only activates when metadata is present.
The Authorize decorator
Annotate controller methods with their required permissions.
import { Authorize } from '@gentleduck/iam/server/nest'
@Controller('posts')
export class PostsController {
@Delete(':id')
@Authorize({ action: 'delete', resource: 'post' })
async deletePost(@Param('id') id: string) {
return this.postsService.delete(id)
}
@Post()
@Authorize({ action: 'create', resource: 'post' })
async createPost(@Body() dto: CreatePostDto) {
return this.postsService.create(dto)
}
// Infer action from HTTP method, resource from route path
@Get()
@Authorize({ infer: true })
async listPosts() {
return this.postsService.list()
}
}
Scoped authorization
Attach a scope to restrict access to a specific context.
@Authorize({ action: 'manage', resource: 'billing', scope: 'admin' })
async updateBilling() { ... }
If no scope is set on the decorator, the guard falls back to the getScope option from the guard factory.
Type-safe decorator
createTypedAuthorize constrains the decorator to your application's exact action, resource, and scope types. Typos become compile-time errors.
import { createTypedAuthorize } from '@gentleduck/iam/server/nest'
type Action = 'create' | 'read' | 'update' | 'delete' | 'manage'
type Resource = 'post' | 'user' | 'billing' | 'report'
type Scope = 'admin' | 'member'
const Auth = createTypedAuthorize<Action, Resource, Scope>()
// This compiles:
@Auth({ action: 'delete', resource: 'post' })
// This is a compile error - "craete" is not in Action:
@Auth({ action: 'craete', resource: 'post' })
Engine provider
Register the engine as a NestJS provider:
import { Module } from '@nestjs/common'
import { APP_GUARD } from '@nestjs/core'
import {
createEngineProvider,
IAM_ACCESS_ENGINE_TOKEN,
nestAccessGuard,
} from '@gentleduck/iam/server/nest'
@Module({
providers: [
createEngineProvider(() => new IamEngine({ adapter })),
{
provide: APP_GUARD,
useFactory: (engine) => ({ canActivate: nestAccessGuard(engine) }),
inject: [IAM_ACCESS_ENGINE_TOKEN],
},
],
})
export class AppModule {}
The engine factory can be async - return a Promise<IamEngine> to support adapter setup that requires await.
Notes
reflect-metadatais optional but recommended. The integration uses it when available and falls back to a property attached directly to the descriptor. In a normal Nest app, loadreflect-metadataonce at the entry point.- No
@Authorize= allowed. A handler without metadata is treated as public. Use route-level Nest guards or other middleware for blanket coverage. onErrorreturns boolean. Default is() => false(deny on engine error). Returntrueonly if you want a fail-open behavior in specific cases.
Options reference
| Option | Type | Default | Description |
|---|---|---|---|
getUserId | (req) -> string or null | req.user?.id ?? req.user?.sub | Extract the subject ID |
getEnvironment | (req) -> Environment | IP + user agent + timestamp | Extract environment context |
getResourceId | (req) -> string or undefined | req.params?.id | Extract the resource instance ID |
getScope | (req) -> string or undefined | undefined | Fallback scope when decorator has none |
onError | (err, req) -> boolean | () => false | Return true to allow on error, false to deny |
Exported tokens
| Token | Type | Purpose |
|---|---|---|
IAM_ACCESS_ENGINE_TOKEN | string ('ACCESS_ENGINE') | DI token for the engine |
IAM_ACCESS_METADATA_KEY | string ('duck-iam:authorize') | Reflect metadata key for the decorator |
Admin operations
NestJS routes via controllers, so duck-iam ships gated admin operations (not a router factory). createAdminOperations returns a record of pre-gated functions to plug into your controller methods. The authorize callback is required - the factory throws if it's missing.
import { Controller, Get, Put, Post, Delete, Inject, Req, Body, Param } from '@nestjs/common'
import { IAM_ACCESS_ENGINE_TOKEN, createAdminOperations } from '@gentleduck/iam/server/nest'
@Controller('admin')
export class IamAdminController {
private readonly h
constructor(@Inject(IAM_ACCESS_ENGINE_TOKEN) engine) {
this.h = createAdminOperations(engine, {
authorize: (req) => req.user?.role === 'platform-admin',
})
}
@Get('policies') listPolicies(@Req() req) { return this.h.listPolicies(req) }
@Get('roles') listRoles(@Req() req) { return this.h.listRoles(req) }
@Put('policies') savePolicy(@Req() req, @Body() body) { return this.h.savePolicy(req, body) }
@Put('roles') saveRole(@Req() req, @Body() body) { return this.h.saveRole(req, body) }
@Post('subjects/:id/roles')
assignRole(@Req() req, @Param('id') id, @Body() body) { return this.h.assignRole(req, id, body) }
@Delete('subjects/:id/roles/:roleId')
revokeRole(@Req() req, @Param('id') id, @Param('roleId') roleId) { return this.h.revokeRole(req, id, roleId) }
}
Every operation throws Error & { status: 401 } when authorize returns false - wire your Nest exception filter to map that to a 401 response, or call authorize yourself inside a @UseGuards() and skip this helper.
CSRF protection (default-on)
Mutation operations (savePolicy, saveRole, assignRole, revokeRole) run
a Sec-Fetch-Site check by default (SEC-103 / CAVEAT-2). Failures throw
Error & { status: 403 }. Browsers populate the header automatically;
cross-site form posts are rejected, 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.
createAdminOperations(engine, { authorize })
// Server-to-server bearer/mTLS API - disable the check entirely.
createAdminOperations(engine, { authorize, csrfCheck: false })
// Stricter - Origin allowlist.
const ADMIN_ORIGINS = new Set(['https://admin.example.com'])
createAdminOperations(engine, {
authorize,
csrfCheck: (req) => ADMIN_ORIGINS.has(req.headers?.origin as string),
})
The admin operations options object conforms to Nest.IAdminOptions with authorize of shape Nest.IAdminAuthorize. The metadata read by the guard from @Authorize conforms to Nest.IAuthorizeMeta, and the guard factory accepts Nest.IGuardOptions.
Types
All types live under the Nest namespace at @gentleduck/iam/server/nest. Type-only - zero bundle cost.
Nest.IAuthorizeMeta- shape of the metadata stamped onto a handler by@AuthorizeandcreateTypedAuthorize.Nest.IGuardOptions- options fornestAccessGuard(getUserId,getEnvironment,getResourceId,getScope,onError).Nest.IAdminAuthorize- signature of the requiredauthorizecallback forcreateAdminOperations.Nest.IAdminOptions- options forcreateAdminOperations.
import type { Nest } from '@gentleduck/iam/server/nest'
const guardOpts: Nest.IGuardOptions = {
getUserId: (req) => req.user?.id ?? req.user?.sub,
}
const meta: Nest.IAuthorizeMeta = { action: 'delete', resource: 'post' }
Deprecated bare aliases (IAuthorizeMeta, IGuardOptions, IAdminAuthorize, IAdminOptions) remain for back-compat and will be removed in 3.0.