@drvalue-oss/iam-nestjs
v0.9.0
Published
NestJS module for drvalue IAM: gateway signature verification, X-User-* parsing, decorators, proxy helpers, outbound subscription-mirror and user-lookup clients
Maintainers
Readme
@drvalue-oss/iam-nestjs
NestJS module for the drvalue IAM platform. Handles:
- HMAC-SHA256 gateway signature verification —
GatewaySignatureGuard, orcreateGatewayAuthMiddleware()/verifyGatewayRequest()for middleware/proxy paths where guards can't run X-User-*header parsing — populates a typedreq.user: IamUserPayload@CurrentUser(),@CurrentGroup(),@Roles(),@Public(),@Authenticated(),@SkipGatewaySignature(),@RequireGatewaySignature()decorators- Proxy helpers —
forwardIamUserHeaders()(one-call strip + re-inject), plus the lower-levelstripClientUserHeaders()/injectIamUserHeaders()
Install
pnpm add @drvalue-oss/iam-nestjs @drvalue-oss/iam-corePeer dependencies: @nestjs/common, @nestjs/core, reflect-metadata, rxjs.
Setup
// app.module.ts
import { Module } from '@nestjs/common';
import { IamModule } from '@drvalue-oss/iam-nestjs';
@Module({
imports: [
IamModule.forRoot({
// Required when enforceGatewayOnly=true. Same secret IAM Gateway uses to sign.
gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,
// PRODUCTION: must be true. Rejects requests without a valid signature.
// DEV: false lets you `curl localhost:3000` directly.
enforceGatewayOnly: process.env.NODE_ENV === 'production',
// Optional. Default ±30,000 ms.
signatureTimestampSkewMs: 30_000,
// Optional. Default true = every route requires an authenticated user
// unless marked @Public(). Set false to make routes public by default
// and opt in with @Authenticated() / @Roles().
secureByDefault: true,
}),
],
})
export class AppModule {}IamModule.forRoot() installs both guards as APP_GUARD-scoped globals by default, so every controller is protected without @UseGuards(). Pass global: false if you want to apply them selectively.
The guards cover two independent axes. Each has a module-level default plus a force-ON and a force-OFF decorator. Method-level decorators override class-level; on a same-level conflict the safe side (protect/require) wins.
| Axis | Module default | Force ON | Force OFF |
|---|---|---|---|
| User auth (IamUserGuard) | secureByDefault (default true) | @Authenticated() / @Roles() | @Public() |
| Gateway signature (GatewaySignatureGuard) | enforceGatewayOnly | @RequireGatewaySignature() | @SkipGatewaySignature() |
@RequireGatewaySignature() forces signature verification on one route even when enforceGatewayOnly is false (e.g. a sensitive endpoint while local dev is otherwise relaxed); if it is required but no gatewaySharedSecret is configured, the request is rejected (fail closed).
Using decorators
import { Controller, Get } from '@nestjs/common';
import {
CurrentUser,
CurrentGroup,
Roles,
Public,
Authenticated,
SkipGatewaySignature,
RequireGatewaySignature,
type IamUserPayload,
type GroupMembership,
} from '@drvalue-oss/iam-nestjs';
@Controller('orders')
export class OrdersController {
@Get('mine')
@Roles('USER') // PLATFORM_ADMIN bypasses
list(@CurrentUser() user: IamUserPayload, @CurrentGroup() group: GroupMembership) {
return { userId: user.sub, groupId: group.id, role: group.role };
}
@Get('me')
@Authenticated() // any logged-in user (only needed when secureByDefault: false)
me(@CurrentUser() user: IamUserPayload) {
return { userId: user.sub };
}
@Get('public-stats')
@Public() // Skip IamUserGuard (signature still required)
stats() {
return { ok: true };
}
@Get('settle')
@Roles('ADMIN')
@RequireGatewaySignature() // always require gateway origin, even in dev
settle() {
return { settled: true };
}
}
@Controller('health')
export class HealthController {
@Get()
@SkipGatewaySignature()
@Public()
health() {
return { status: 'ok' };
}
}Acting as a further proxy (BFF / gateway)
http-proxy-middleware runs as middleware, which executes before NestJS
guards — so on a proxied route the guards never run and req.user is never set.
Verify the gateway signature and populate req.user yourself with
createGatewayAuthMiddleware(), then re-inject the user with forwardIamUserHeaders():
// app.module.ts
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { IamModule, createGatewayAuthMiddleware, forwardIamUserHeaders } from '@drvalue-oss/iam-nestjs';
import { createProxyMiddleware } from 'http-proxy-middleware';
const apiUserProxy = createProxyMiddleware({
target: 'http://api-user:3001',
changeOrigin: true,
// NOTE: the forwarded request keeps the original gateway signature, which no
// longer verifies downstream. If the downstream runs `enforceGatewayOnly: true`,
// re-sign for the next hop — see "Re-signing for a second hop" below:
// forwardIamUserHeaders(proxyReq, req, { resign: { secret: process.env.DOWNSTREAM_GATEWAY_SECRET! } })
on: { proxyReq: (proxyReq, req) => forwardIamUserHeaders(proxyReq, req) },
});
@Module({ imports: [IamModule.forRoot({ gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!, global: false })] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(
// verify gateway origin (HMAC binds all X-User-* identity headers) + set req.user, then proxy
createGatewayAuthMiddleware({ secret: process.env.GATEWAY_SHARED_SECRET! }),
apiUserProxy,
)
.forRoutes('api-user');
}
}
createGatewayAuthMiddlewarerejects unsigned/forged requests with403and is the same HMAC checkGatewaySignatureGuarddoes — done at the middleware layer where guards can't reach. Need just the primitives?verifyGatewayRequest(req, secret)returns{ ok, reason, user }, andverifyGatewaySignature(params)is the pure core (the canonical binds timestamp, method, path, and all sevenX-User-*headers).If you instead run the proxy from inside a guarded controller (
@All('*')), the guards already populatereq.user— thereforwardIamUserHeaders(proxyReq, req)alone is enough.
Heads-up: the request forwarded this way still carries the original gateway signature, but it will no longer verify downstream —
X-User-Groups-Detailis stripped without being re-injected, and the signed path/timestamp are bound to this hop. Downstream services must either run with signature verification off behind a network policy, or have this BFF re-sign — see Re-signing for a second hop.
Verifying only some paths (path.include)
When the middleware is mounted broadly (a global app.use(...), or a proxy in
front of NestJS routing) you may want it to verify only a subset of paths and
let the rest through untouched. Pass path.include — a whitelist of matchers:
app.use(
createGatewayAuthMiddleware({
secret: process.env.GATEWAY_SHARED_SECRET!,
// Only these paths are verified; everything else is passed straight through.
path: { include: ['/login/root/iam', '/api/**'] },
}),
);A request whose path matches any entry is verified exactly as before
(403 on a bad/missing signature). A request that matches none is passed
straight to next() — not verified, with req.user / req.iamGatewayVerified
left unset. Each matcher is a glob string (* = one non-slash segment, ** =
any run including slashes), a RegExp, or a (path) => boolean predicate.
Omit path.include to verify every request (the default).
Security:
includemakes the unmatched paths unprotected by this middleware. Only narrow it for routes you intend to leave open — if you are registering through NestJS'sMiddlewareConsumer, prefer.forRoutes()/.exclude()so route scoping lives in one place.
Re-signing for a second hop (BFF → downstream)
The gateway signature binds the request path, so once a BFF proxies onward
(path rewrite, header re-injection, timestamp aging) the original signature can
never verify downstream.
Either disable signature verification downstream and rely on a network policy,
or have the BFF re-sign for the next hop so the downstream service keeps
enforceGatewayOnly: true:
const downstreamProxy = createProxyMiddleware({
target: 'http://auth-svc:3001',
changeOrigin: true,
on: {
proxyReq: (proxyReq, req) =>
forwardIamUserHeaders(proxyReq, req, {
resign: { secret: process.env.DOWNSTREAM_GATEWAY_SECRET! },
}),
},
});
app.use(
'/auth',
createGatewayAuthMiddleware({ secret: process.env.GATEWAY_SHARED_SECRET! }),
downstreamProxy,
);With resign set, forwardIamUserHeaders:
- strips client
X-User-*headers and re-injects the verified user (as before), - unconditionally drops (even when the request is unverified) the inbound
X-Gateway-Signature/X-Gateway-Timestamp(they are bound to the previous hop's path — dead weight downstream), and - signs the outgoing request against the path the downstream service will
see — only when
req.iamGatewayVerified === true, i.e. whencreateGatewayAuthMiddleware(orGatewaySignatureGuard) cryptographically verified the inbound signature. Unverified requests go out unsigned and are rejected by the downstream'senforceGatewayOnly(fail closed).
Notes:
- A verified anonymous request is re-signed too (the gateway signs those
with all identity values empty) —
req.userpresence is deliberately NOT the re-sign condition. - Prefer a different secret per hop: if a downstream service leaks its
secret, the attacker still cannot forge requests into the edge. An empty
resign.secretthrows. X-User-Groups-Detailis not re-injected byforwardIamUserHeaders, so it is signed as empty downstream; parse it at the BFF if you need it.X-User-Active-GroupandX-User-Phoneare never signature-protected, on any hop — do not make authorization decisions from them.- Re-signing mints a fresh timestamp, so the downstream ±30s skew window re-anchors at the BFF; like the gateway scheme itself there is no nonce, so pair it with TLS / a trusted network between hops.
- Low-level primitive:
signGatewayRequest(proxyReq, { secret, method?, path?, now? })signs any outgoing request whose headers are final.
Pushing subscription state to IAM (outbound)
Everything above is inbound (verifying requests from IAM Gateway). The module
also ships an outbound client, IamSubscriptionService, so a product backend
can mirror subscription state into IAM from its own billing/webhook handlers. IAM
renders that mirror on the user's "내 구독 관리" portal and the admin dashboard.
It calls IAM's machine-to-machine API
(PUT/DELETE /internal/api/products/{productSlug}/subscriptions/{userId}),
authenticated with X-Internal-Api-Key. Configure it in forRoot():
IamModule.forRoot({
gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,
enforceGatewayOnly: process.env.NODE_ENV === 'production',
// Outbound subscription client (omit if you don't push subscriptions):
internalApiBaseUrl: process.env.IAM_INTERNAL_API_BASE_URL!, // e.g. http://iam-server:10732
internalApiKey: process.env.INTERNAL_API_KEY!, // X-Internal-Api-Key
productSlug: 'hangeon-chat', // default; overridable per call
});Inject it where your billing events land. userId is the IAM user UUID —
your backend owns the mapping from its own customer id.
import { Injectable } from '@nestjs/common';
import { IamSubscriptionService } from '@drvalue-oss/iam-nestjs';
@Injectable()
export class BillingWebhookService {
constructor(private readonly subscriptions: IamSubscriptionService) {}
// Register or update — idempotent on (userId, productSlug). The first call
// registers; later calls update in place. Use for every paid-state change.
async onPaid(userId: string, nextBillingAt: Date) {
await this.subscriptions.upsert(userId, {
tier: 'Pro',
priceKrw: 12000,
status: 'ACTIVE',
nextBillingAt,
manageUrl: 'https://pay.example.com/manage',
});
}
// "FREE 이용 중" card — push a free mirror (e.g. on signup) so the user always
// has a card. Forces priceKrw 0 / status ACTIVE / metadata.is_free_tier = true.
async onSignup(userId: string) {
await this.subscriptions.registerFree(userId, {
manageUrl: 'https://pay.example.com/pricing', // "업그레이드 알아보기" link
});
}
// Cancel — renewal stops; tier functional until graceUntil. Thin wrapper that
// forces status CANCELED (the server keeps no read endpoint, so pass full state).
async onCanceled(userId: string, graceUntil: Date) {
await this.subscriptions.cancel(userId, {
tier: 'Pro',
priceKrw: 12000,
graceUntil,
manageUrl: 'https://pay.example.com/manage',
});
}
// Remove the mirror entirely (e.g. GDPR erasure). For routine downgrades push
// status: 'EXPIRED' via upsert instead — that keeps the row for support queries.
async onErased(userId: string) {
await this.subscriptions.delete(userId);
}
}Statuses mirror IAM's narrow enum: ACTIVE / CANCELED / EXPIRED. There is
no FREE status — a free plan is the metadata.is_free_tier: true flag (set for
you by registerFree), which IAM renders as a "FREE 이용 중" card.
Date fields (currentPeriodStart, currentPeriodEnd, graceUntil,
nextBillingAt) accept a Date or a string. IAM stores them as Java
LocalDateTime (zone-naive). A Date is serialized to a bare UTC wall-clock
at seconds precision (2026-07-01T00:00:00, no offset), which the server always
accepts. A string is passed through verbatim — keep it offset-free: the
server tolerates a trailing Z (silently dropping it) but rejects a numeric
offset like +09:00 with a 400. An invalid Date (NaN) throws a clear
error before the request is sent.
Errors: a non-2xx response throws IamInternalApiError (.status, .body,
.method, .url); network failures propagate as the raw fetch rejection. The
productSlug and userId are URL-encoded; an unknown userId returns 400.
Config is validated lazily — the first call throws a clear error if
internalApiBaseUrl / internalApiKey / productSlug are missing.
Uses the global
fetch(Node ≥20.19) — no extra runtime dependency. Your service must have network reach to the IAM internal API and hold theINTERNAL_API_KEY; treat it as a secret (it bypasses the gateway).
Looking up users (outbound)
The module also ships IamUserService, an outbound client for IAM's user-lookup
internal API. The main reason it exists: the phone number is intentionally
absent from the JWT and the gateway X-User-* headers (per IAM's V19 hardening),
so a backend that needs it fetches it server-to-server here instead of
trusting a forwarded header.
It uses the same internalApiBaseUrl + internalApiKey config as the
subscription client (no productSlug needed):
IamModule.forRoot({
gatewaySharedSecret: process.env.GATEWAY_SHARED_SECRET!,
enforceGatewayOnly: process.env.NODE_ENV === 'production',
internalApiBaseUrl: process.env.IAM_INTERNAL_API_BASE_URL!, // e.g. http://iam-server:10732
internalApiKey: process.env.INTERNAL_API_KEY!, // X-Internal-Api-Key
});Inject it and resolve a user by id, or by a single natural key (email / phone / username):
import { Injectable } from '@nestjs/common';
import { IamUserService } from '@drvalue-oss/iam-nestjs';
@Injectable()
export class NotificationService {
constructor(private readonly users: IamUserService) {}
// By IAM user UUID — e.g. read the phone the gateway never forwards.
async sendSms(userId: string, message: string) {
const user = await this.users.getById(userId);
await this.sms.send(user.phone, message);
}
// By natural key — exactly one of email / phone / username. Validated
// client-side first, so passing zero or two keys throws before any request.
async findByEmail(email: string) {
return this.users.lookup({ email }); // → IamUserDetail
}
}The response (IamUserDetail) carries id, username, name, email,
phone, phoneVerified, role, enabled, lastLoginAt, createdAt,
updatedAt. Date fields are zone-naive strings; name / lastLoginAt /
updatedAt may be null.
Errors match the subscription client: a non-2xx throws IamInternalApiError
(.status, .body, .method, .url) — an unknown user is 404. Config is
validated lazily on first use.
⚠️ This response is PII (phone, email). Keep it server-side; never relay it verbatim to an untrusted client.
Security notes
IamUserGuarddoes NOT verify the JWT. Trust is established byGatewaySignatureGuard+ a network policy that limits ingress to IAM Gateway only. Without the network policy, an attacker who can reach your service directly can forgeX-User-Id: 1and impersonate any user —enforceGatewayOnly: trueis your only line of defense. The guard logs a warning at startup whenenforceGatewayOnlyis false.@SkipGatewaySignature()routes must also be@Public()(or must not rely onreq.user). Skipping the signature removes the proof of gateway origin, so theX-User-*headers on that route are forgeable — combining it with@Roles()/@Authenticated()and trusting the user is an auth bypass.PLATFORM_ADMINbypasses all@Roles()checks. Encode group-scoped role checks in a separate guard againstuser.activeGroup.role.@Roles()requires at least one role (an empty call throws at startup). A method-level@Roles()/@Authenticated()overrides class-level@Public(), and a method-level@Roles()fully replaces a class-level@Roles()(the class is a default, not a floor) — audit controllers that mix these.
