@globaltracking/auth-middleware
v3.2.0
Published
Unified authentication and authorization middleware for the Global Tracking platform (Express + NestJS)
Maintainers
Readme
@globaltracking/auth-middleware
Unified authentication and authorization middleware for the Global Tracking platform. Shared by all backend microservices — supports both Express and NestJS.
Features
- Strategy pattern: Gateway header, trusted headers, JWT — configurable per service
- NestJS adapter:
GtAuthModule, guards, decorators, interceptors, exception filter - Express middleware:
authenticate,requirePermission,requireRole,requireSelf,requireTenant - Hybrid permission resolution: JWT claims (fast) → custom resolver → RBAC HTTP call → deny (fail-closed)
- Multi-tenant RLS:
OrgContextInterceptorsetsSET LOCAL app.current_org_idfor PostgreSQL Row-Level Security - Admin bypass: Configurable
adminRoles[]bypass all permission checks - TypeScript-first: Full type definitions with Express
Requestaugmentation - 98%+ test coverage: 89 tests across strategies, middlewares, and config
Installation
npm install @globaltracking/auth-middlewarePeer dependencies:
| Package | Required for | Optional? |
|---------|-------------|-----------|
| express ^4.18 | Express middleware | Required |
| @nestjs/common ^11.0 | NestJS adapter | Optional |
| @nestjs/core ^11.0 | NestJS adapter | Optional |
| typeorm ^0.3.0 | OrgContextInterceptor (RLS) | Optional |
| rxjs ^7.0 | OrgContextInterceptor | Optional |
NestJS Integration (Recommended)
This is the primary integration path for Global Tracking microservices.
1. Import GtAuthModule in your AppModule
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GtAuthModule } from '@globaltracking/auth-middleware/nestjs';
@Module({
imports: [
// ... ConfigModule, TypeOrmModule, ThrottlerModule ...
GtAuthModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
strategies: ['trusted-headers'],
internalGatewayToken: config.get('INTERNAL_GATEWAY_TOKEN'),
adminRoles: ['system_admin', 'org_admin'],
rbacServiceUrl: config.get('RBAC_SERVICE_URL'),
}),
}),
// ... domain modules ...
],
})
export class AppModule {}What GtAuthModule provides (globally):
| Component | What it does |
|-----------|-------------|
| GtTrustedHeadersMiddleware | Extracts req.user from configured strategy chain (auto-applied to all routes) |
| InternalOnlyGuard | Validates X-Gateway-Token header — attach per-route with @UseGuards() or register as APP_GUARD |
| GtPermissionsGuard | Checks @RequirePermissions() — auto-registered as APP_GUARD since v3 (opt out with registerGuardGlobally: false) |
| OrgContextInterceptor | Sets PostgreSQL RLS context — register as APP_INTERCEPTOR |
| AuthExceptionFilter | Catches AuthError and returns standard error envelope |
2. Use decorators in controllers
import {
RequirePermissions,
CurrentUser,
CurrentOrg,
Public,
RequireRoles,
} from '@globaltracking/auth-middleware/nestjs';
@Controller('vehicles')
export class VehiclesController {
@Post()
@RequirePermissions('vehicles:create')
create(
@CurrentOrg() orgId: string,
@CurrentUser('userId') userId: string,
@Body() dto: CreateVehicleDto,
) {
// orgId and userId extracted from trusted headers
}
@Get(':id')
@RequirePermissions('vehicles:read')
findOne(
@CurrentOrg() orgId: string,
@Param('id', ParseUUIDPipe) id: string,
) { ... }
@Delete(':id')
@RequireRoles('system_admin')
remove(@Param('id', ParseUUIDPipe) id: string) { ... }
}// Health endpoints skip auth
@Controller('health')
export class HealthController {
@Get()
@Public()
liveness() {
return { status: 'ok' };
}
}3. Register guards and interceptors
Since v3, GtPermissionsGuard is auto-registered as an APP_GUARD by
GtAuthModule.forRoot() / forRootAsync(). You only need to wire the
interceptor and any service-specific guards:
// app.module.ts providers
import {
OrgContextInterceptor,
} from '@globaltracking/auth-middleware/nestjs';
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: OrgContextInterceptor },
// ... your other interceptors
],Opt-out: pass registerGuardGlobally: false if your service composes its
own guard chain and wants to attach GtPermissionsGuard manually:
GtAuthModule.forRootAsync({
registerGuardGlobally: false,
inject: [ConfigService],
useFactory: (c: ConfigService) => ({ ... }),
})
// then in providers:
{ provide: APP_GUARD, useClass: MyCompositeGuard }, // wraps Gt checks itselfInternalOnlyGuard is not auto-registered — attach it per-route via
@UseGuards(InternalOnlyGuard) on internal-only endpoints, or register it
globally if every route in the service is internal-only.
4. Add env vars
INTERNAL_GATEWAY_TOKEN=your-32-char-min-secret
RBAC_SERVICE_URL=http://gt-rbac-service:3000 # optional, for permission resolutionSpecial case: gt-rbac-service
The RBAC service cannot call itself for permission resolution. Instead, inject its own ResolveService via permissionResolver:
GtAuthModule.forRootAsync({
imports: [ResolveModule],
inject: [ConfigService, ResolveService],
useFactory: (config: ConfigService, resolveService: ResolveService) => ({
strategies: ['trusted-headers'],
internalGatewayToken: config.get('INTERNAL_GATEWAY_TOKEN'),
adminRoles: ['system_admin', 'org_admin'],
permissionResolver: (orgId, userId, resource, action) =>
resolveService.checkPermission(orgId, userId, resource, action),
}),
}),Express Integration
For services that use plain Express (no NestJS):
import express from 'express';
import {
initAuth,
authenticate,
requirePermission,
requireTenant,
authErrorHandler,
} from '@globaltracking/auth-middleware';
const app = express();
// Initialize once at startup
initAuth({
strategies: ['gateway-header', 'jwt'],
adminRoles: ['system_admin', 'org_admin'],
publicKeyPath: './keys/public.pem',
});
// Protected route
app.get('/v1/vehicles',
authenticate,
requireTenant,
requirePermission('vehicles:read'),
async (req, res) => {
// req.user.userId, req.user.orgId, etc.
// req.tenantId set by requireTenant
}
);
// Error handler (must be last)
app.use(authErrorHandler);Configuration
initAuth({
// Strategy chain — tried in order until one matches
strategies: ['gateway-header', 'jwt'], // default
// Gateway header name (GCP API Gateway)
gatewayHeaderName: 'x-apigateway-api-userinfo', // default
// JWT verification (for local dev / non-gateway)
jwtIssuer: 'globaltracking-auth', // default
publicKey: '-----BEGIN PUBLIC KEY-----\n...', // PEM string
publicKeyPath: './keys/public.pem', // or file path
// Roles that bypass all permission checks
adminRoles: ['system_admin', 'org_admin'], // default
// Trusted headers strategy config
internalGatewayToken: 'secret', // also reads env INTERNAL_GATEWAY_TOKEN
trustedHeaderNames: {
userId: 'x-user-id', // default
orgId: 'x-org-id', // default
userRole: 'x-user-role', // default
requestId: 'x-request-id', // default
gatewayToken: 'x-gateway-token', // default
},
// Permission resolution (NestJS GtPermissionsGuard)
rbacServiceUrl: 'http://gt-rbac-service:3000', // HTTP fallback
permissionResolver: async (orgId, userId, resource, action) => {
// Custom resolver (e.g., direct DB call)
return true;
},
});Public key resolution order: publicKey → publicKeyPath → AUTH_PUBLIC_KEY env → AUTH_PUBLIC_KEY_PATH env.
Auth Strategies
| Strategy | When used | What it reads |
|----------|-----------|---------------|
| gateway-header | Production (GCP API Gateway) | Base64-encoded JSON in X-Apigateway-Api-Userinfo |
| trusted-headers | NestJS services behind API Gateway | X-User-Id, X-Org-Id, X-User-Role, X-Request-Id, X-Gateway-Token |
| jwt | Local dev, non-gateway environments | Authorization: Bearer <token> (RS256 verification) |
The strategy chain iterates in the configured order. The first strategy whose canHandle(req) returns true is used.
AuthUser Shape
Every authenticated request gets req.user populated with:
interface AuthUser {
userId: string; // from JWT sub or X-User-Id
email: string; // from JWT email (empty in trusted-headers mode)
role: UserRole; // from JWT role or X-User-Role
orgId: string; // from JWT org_id or X-Org-Id
tenantId: string; // = orgId in trusted-headers mode
permissions: string[]; // from JWT claims (empty in trusted-headers mode)
requestId: string; // from X-Request-Id
authSource: AuthStrategy; // 'gateway-header' | 'trusted-headers' | 'jwt'
}Permission Resolution (GtPermissionsGuard)
The NestJS GtPermissionsGuard resolves permissions in 3 tiers:
- JWT claims — If
user.permissionsis non-empty, check in-memory (fast path) - permissionResolver — If configured, call the function directly (for RBAC service)
- RBAC HTTP call — If
rbacServiceUrlis configured, POST to/v1/resolve/check - Deny — Fail-closed if no resolution mechanism is available
Admin roles (config.adminRoles) always bypass all checks.
Express Middlewares
| Middleware | Purpose |
|-----------|---------|
| authenticate | Extracts req.user from strategy chain |
| requireRole(...roles) | Checks req.user.role against allowed roles |
| requirePermission(...perms) | Requires ALL listed permissions (admin bypass) |
| requireAnyPermission(...perms) | Requires AT LEAST ONE permission (admin bypass) |
| requireTenant | Ensures req.user.tenantId exists, sets req.tenantId |
| requireSelf(paramName?) | Compares req.user.userId with route param (admin bypass) |
| authErrorHandler | Error handler for AuthError subclasses |
Error Handling
All auth errors extend AuthError and produce the standard Global Tracking error envelope:
import { UnauthorizedError, ForbiddenError } from '@globaltracking/auth-middleware';
throw new UnauthorizedError('Token expired');
// → 401 { success: false, error: { code: 'UNAUTHORIZED', message: 'Token expired', statusCode: 401 } }
throw new ForbiddenError('Insufficient permissions', { required: ['vehicles:write'] });
// → 403 { success: false, error: { code: 'FORBIDDEN', message: 'Insufficient permissions', statusCode: 403 } }Utility Functions
import { extractUser, hasRole, hasPermission, hasAnyPermission } from '@globaltracking/auth-middleware';
const user = extractUser(req); // throws UnauthorizedError if missing
hasRole(user, 'system_admin'); // boolean
hasPermission(user, 'vehicles:read'); // all must match
hasAnyPermission(user, 'a:read', 'b:read'); // at least oneFiles Replaced Per Service
When a NestJS service adopts this package, these ~10 files can be deleted:
src/common/decorators/current-org.decorator.ts → @CurrentOrg from package
src/common/decorators/current-user.decorator.ts → @CurrentUser from package
src/common/decorators/permissions.decorator.ts → @RequirePermissions from package
src/common/decorators/public.decorator.ts → @Public from package
src/common/guards/internal-only.guard.ts → InternalOnlyGuard from package
src/common/guards/permissions.guard.ts → GtPermissionsGuard from package
src/common/interceptors/org-context.interceptor.ts → OrgContextInterceptor from package
src/common/middleware/trusted-headers.middleware.ts → GtTrustedHeadersMiddleware from package
src/common/interfaces/jwt-payload.interface.ts → AuthUser from package
src/common/interfaces/request-context.interface.ts → (Express augmentation from package)Development
npm test # run tests with coverage (89 tests, 98%+ coverage)
npm run test:watch # watch mode
npm run build # compile to dist/
npm run clean # remove dist/Migration from v2.x → v3.0
Breaking: GtAuthModule.forRoot() / forRootAsync() now auto-registers
GtPermissionsGuard as an APP_GUARD. In v2.x the guard was provided but
never globally registered, which meant every @RequirePermissions()
decorator was silently inert (routes returned 2xx for users who lacked the
declared permission — a platform-wide security defect).
What you need to do:
Remove any existing
{ provide: APP_GUARD, useClass: GtPermissionsGuard }line from your service'sapp.module.ts— the library now registers it for you. Leaving it in place only double-runs the guard (still correct, just wasteful).Audit for endpoints that were accidentally working without the correct permission. Any route missing the right
@RequirePermissions(...)decorator will start returning 403 to non-admin users. Grep for missing decorators on controllers that use other guards already; add the decorator or annotate with@Public()as appropriate.If your service composes its own guard chain (e.g. gt-alert-engine's custom
GatewayAuthGuard/OrgIdGuardstack) and you do not want the automatic registration, passregisterGuardGlobally: false:GtAuthModule.forRootAsync({ registerGuardGlobally: false, ... })
Migration from v1.x
superAdminRole→ useadminRoles: ['system_admin', 'org_admin'](legacysuperAdminRolestill works)RequestUsertype alias still exported for backward compatibility- Error handler now returns
{ success: false, error: { code, message, statusCode } }envelope instead of{ error: message } extractUser()now setsauthSourceandrequestIdon returned user
