Auth & Security¶
Authentication verifies identity; authorization enforces permissions. Warmwind uses JWT with Passport.js, refresh token rotation, OAuth2 PKCE for SSO, RBAC via CASL, and defense-in-depth layers (rate limiting, Helmet, CORS, CSP, Argon2id, class-validator, API key management). Every pattern is shown with production-grade NestJS code and the rationale behind each decision.
1. JWT Authentication with Passport.js¶
1.1 Full Auth Flow¶
sequenceDiagram
participant C as Client (SPA)
participant A as NestJS API
participant DB as PostgreSQL
participant R as Redis (token store)
C->>A: POST /auth/login { email, password }
A->>DB: SELECT password_hash FROM users WHERE email = $1
A->>A: argon2.verify(hash, password)
A->>R: Store refresh token (family, userId, jti)
A-->>C: 200 { accessToken (15m), refreshToken (7d) }
Note over C: Stores accessToken in memory,<br/>refreshToken in httpOnly cookie
C->>A: GET /sessions (Authorization: Bearer <accessToken>)
A->>A: JwtStrategy.validate() extracts payload
A->>A: RolesGuard checks permissions
A-->>C: 200 [sessions...]
C->>A: POST /auth/refresh (cookie: refreshToken)
A->>R: Validate token, check revoked flag
A->>R: Revoke old token, issue new pair
A-->>C: 200 { accessToken, refreshToken }
1.2 JWT Strategy Implementation¶
// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
export interface JwtPayload {
sub: string; // user ID
email: string;
tenantId: string;
roles: string[];
iat: number;
exp: number;
jti: string; // unique token ID for revocation
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private config: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
algorithms: ['HS256'],
ignoreExpiration: false,
});
}
async validate(payload: JwtPayload) {
// Returned object is attached to req.user
return {
id: payload.sub,
email: payload.email,
tenantId: payload.tenantId,
roles: payload.roles,
};
}
}
Coming from Spring Security
In Spring, JwtDecoder is configured via SecurityFilterChain with
.oauth2ResourceServer(oauth2 -> oauth2.jwt()). Passport.js strategies
serve the same role but are explicit callback-based rather than declarative.
The key mapping: Spring's JwtAuthenticationConverter is TypeORM's
validate() method -- it transforms the raw JWT payload into your
application's user representation.
1.3 Auth Service -- Token Issuance¶
// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import { UserRepository } from '../users/user.repository';
import { RefreshTokenRepository } from './refresh-token.repository';
@Injectable()
export class AuthService {
constructor(
private jwt: JwtService,
private config: ConfigService,
private users: UserRepository,
private refreshTokens: RefreshTokenRepository,
) {}
async login(email: string, password: string) {
const user = await this.users.findByEmailWithPassword(email);
if (!user) throw new UnauthorizedException('Invalid credentials');
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) throw new UnauthorizedException('Invalid credentials');
return this.issueTokenPair(user.id, user.email, user.tenantId, user.roles);
}
private async issueTokenPair(
userId: string,
email: string,
tenantId: string,
roles: string[],
family?: string,
) {
const tokenFamily = family ?? uuidv4();
const jti = uuidv4();
const accessToken = this.jwt.sign(
{ sub: userId, email, tenantId, roles, jti },
{
secret: this.config.getOrThrow('JWT_ACCESS_SECRET'),
expiresIn: '15m',
},
);
const refreshToken = this.jwt.sign(
{ sub: userId, family: tokenFamily, jti: uuidv4() },
{
secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
expiresIn: '7d',
},
);
// Persist refresh token for rotation tracking
await this.refreshTokens.create({
token: refreshToken,
userId,
family: tokenFamily,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
return { accessToken, refreshToken };
}
}
2. Refresh Token Rotation¶
2.1 Why Rotation?¶
A stolen refresh token is dangerous -- it grants long-lived access. Rotation ensures each refresh token is single-use. If an attacker replays a previously used token, the entire token family is revoked, forcing re-login.
2.2 Implementation¶
// auth/auth.service.ts (continued)
async refreshTokens(oldRefreshToken: string) {
let decoded: { sub: string; family: string; jti: string };
try {
decoded = this.jwt.verify(oldRefreshToken, {
secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
const stored = await this.refreshTokens.findByToken(oldRefreshToken);
// Case 1: token not found or already revoked -> token reuse attack
if (!stored || stored.revoked) {
// Revoke the ENTIRE family -- attacker and legitimate user both lose access
await this.refreshTokens.revokeFamily(decoded.family);
throw new UnauthorizedException('Token reuse detected -- family revoked');
}
// Case 2: valid token -- rotate
await this.refreshTokens.revoke(stored.id);
const user = await this.users.findByIdOrFail(decoded.sub);
return this.issueTokenPair(
user.id, user.email, user.tenantId, user.roles,
decoded.family, // preserve the family for tracking
);
}
2.3 Refresh Token Entity¶
// entities/refresh-token.entity.ts
@Entity('refresh_tokens')
@Index(['family'])
@Index(['userId'])
export class RefreshToken {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
token: string;
@Column({ type: 'uuid', name: 'user_id' })
userId: string;
@Column({ type: 'uuid' })
family: string;
@Column({ default: false })
revoked: boolean;
@Column({ type: 'timestamptz', name: 'expires_at' })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
2.4 Database Cleanup¶
-- Periodic job: delete expired and revoked tokens
DELETE FROM refresh_tokens
WHERE expires_at < NOW() - INTERVAL '1 day'
OR revoked = true;
3. OAuth2 Authorization Code + PKCE¶
3.1 Why PKCE?¶
PKCE (RFC 7636) prevents authorization code interception attacks. Without it, a
malicious app that intercepts the redirect URI can exchange the auth code. PKCE
binds the code to a code_verifier that only the original client knows.
3.2 Sequence Diagram¶
sequenceDiagram
participant B as Browser (SPA)
participant A as NestJS API
participant IdP as Identity Provider (Entra ID)
Note over B: Generate code_verifier (43-128 chars)<br/>code_challenge = SHA256(verifier) base64url
B->>A: GET /auth/oidc/login
A-->>B: 302 → IdP /authorize?<br/>response_type=code&<br/>client_id=xxx&<br/>redirect_uri=.../callback&<br/>scope=openid profile email&<br/>code_challenge=abc123&<br/>code_challenge_method=S256&<br/>state=random-csrf-token
B->>IdP: User authenticates (MFA, password, etc.)
IdP-->>B: 302 → /auth/oidc/callback?code=AUTH_CODE&state=random-csrf-token
B->>A: GET /auth/oidc/callback?code=AUTH_CODE&state=...
A->>IdP: POST /token {<br/> grant_type: authorization_code,<br/> code: AUTH_CODE,<br/> code_verifier: original_verifier,<br/> client_id: xxx,<br/> redirect_uri: .../callback<br/>}
IdP->>IdP: Verify SHA256(code_verifier) == code_challenge
IdP-->>A: { id_token, access_token }
A->>A: Validate id_token, extract claims
A->>A: Find-or-create local user
A-->>B: 200 { accessToken (15m), refreshToken (7d) }
3.3 NestJS Implementation¶
// auth/strategies/oidc.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Client, Issuer, generators } from 'openid-client';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') {
constructor(private config: ConfigService) {
const issuer = new Issuer({
issuer: config.getOrThrow('OIDC_ISSUER'),
authorization_endpoint: config.getOrThrow('OIDC_AUTH_ENDPOINT'),
token_endpoint: config.getOrThrow('OIDC_TOKEN_ENDPOINT'),
jwks_uri: config.getOrThrow('OIDC_JWKS_URI'),
userinfo_endpoint: config.getOrThrow('OIDC_USERINFO_ENDPOINT'),
});
const client = new issuer.Client({
client_id: config.getOrThrow('OIDC_CLIENT_ID'),
client_secret: config.get('OIDC_CLIENT_SECRET'), // optional for public clients
redirect_uris: [config.getOrThrow('OIDC_REDIRECT_URI')],
response_types: ['code'],
token_endpoint_auth_method: 'none', // PKCE public client
});
super({
client,
params: {
scope: 'openid profile email',
code_challenge_method: 'S256',
},
usePKCE: true,
});
}
async validate(tokenset: any) {
const claims = tokenset.claims();
return {
oidcId: claims.sub,
email: claims.email,
name: claims.name,
// Tenant mapping happens in AuthService.findOrCreateOidcUser()
};
}
}
// auth/auth.controller.ts (OIDC routes)
@Controller('auth/oidc')
export class OidcController {
constructor(private authService: AuthService) {}
@Get('login')
@UseGuards(AuthGuard('oidc'))
login() {
// Passport redirects to IdP
}
@Get('callback')
@UseGuards(AuthGuard('oidc'))
async callback(@Req() req: Request) {
const user = await this.authService.findOrCreateOidcUser(req.user);
return this.authService.issueTokenPair(
user.id, user.email, user.tenantId, user.roles,
);
}
}
Coming from Spring Security
Spring Boot's spring-security-oauth2-client auto-configures PKCE when
client-authentication-method: none is set. In NestJS, openid-client +
passport with usePKCE: true is the equivalent. The main difference:
Spring auto-discovers the IdP via .well-known/openid-configuration
whereas openid-client also supports discovery via Issuer.discover(url).
4. RBAC with CASL¶
4.1 Why CASL over Simple Role Guards?¶
NestJS RolesGuard checks "does user have role X?" -- binary yes/no. CASL adds
attribute-based rules: "can this admin manage containers in their own
tenant?" and "can this viewer read sessions they own?"
4.2 Ability Factory¶
// auth/casl/ability.factory.ts
import { AbilityBuilder, PureAbility, createMongoAbility } from '@casl/ability';
import { Injectable } from '@nestjs/common';
export enum Action {
Manage = 'manage', // wildcard: all actions
Create = 'create',
Read = 'read',
Update = 'update',
Delete = 'delete',
}
export type AppAbility = PureAbility<[Action, string]>;
@Injectable()
export class AbilityFactory {
createForUser(user: { id: string; tenantId: string; roles: string[] }): AppAbility {
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
if (user.roles.includes('admin')) {
// Admin can manage everything within their tenant
can(Action.Manage, 'all');
cannot(Action.Delete, 'Tenant'); // even admins cannot delete the tenant itself
}
if (user.roles.includes('operator')) {
can(Action.Create, 'AgentSession');
can(Action.Read, 'AgentSession', { tenantId: user.tenantId });
can(Action.Update, 'AgentSession', { userId: user.id }); // only own sessions
can(Action.Read, 'Container', { tenantId: user.tenantId });
can(Action.Read, 'AgentAction');
}
if (user.roles.includes('viewer')) {
can(Action.Read, 'AgentSession', { tenantId: user.tenantId });
can(Action.Read, 'Container', { tenantId: user.tenantId });
can(Action.Read, 'AgentAction');
}
return build();
}
}
4.3 CASL Guard¶
// auth/casl/policies.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AbilityFactory, Action, AppAbility } from './ability.factory';
export interface PolicyHandler {
handle(ability: AppAbility): boolean;
}
export const CHECK_POLICIES_KEY = 'check_policies';
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: AbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
const handlers = this.reflector.get<PolicyHandler[]>(
CHECK_POLICIES_KEY,
context.getHandler(),
) ?? [];
if (handlers.length === 0) return true;
const user = context.switchToHttp().getRequest().user;
const ability = this.abilityFactory.createForUser(user);
const allowed = handlers.every((handler) => handler.handle(ability));
if (!allowed) throw new ForbiddenException('Insufficient permissions');
return true;
}
}
4.4 Usage on Controller¶
// Policy handler objects
export class ReadSessionPolicy implements PolicyHandler {
handle(ability: AppAbility) {
return ability.can(Action.Read, 'AgentSession');
}
}
export class DeleteContainerPolicy implements PolicyHandler {
handle(ability: AppAbility) {
return ability.can(Action.Delete, 'Container');
}
}
// Controller
@Controller('sessions')
@UseGuards(JwtAuthGuard, PoliciesGuard)
export class SessionController {
@Get()
@CheckPolicies(new ReadSessionPolicy())
findAll(@Req() req: Request) { /* ... */ }
@Delete(':id')
@CheckPolicies(new DeleteContainerPolicy())
remove(@Param('id') id: string) { /* ... */ }
}
5. Rate Limiting Auth Endpoints¶
// main.ts
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
@Module({
imports: [
ThrottlerModule.forRoot([
{ name: 'short', ttl: 60_000, limit: 20 }, // 20 req/min global
{ name: 'medium', ttl: 600_000, limit: 100 }, // 100 req/10min global
]),
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
// auth/auth.controller.ts
@Controller('auth')
export class AuthController {
@Post('login')
@Throttle({ short: { limit: 5, ttl: 60_000 } }) // 5 attempts/min
async login(@Body() dto: LoginDto) { /* ... */ }
@Post('refresh')
@Throttle({ short: { limit: 3, ttl: 60_000 } }) // 3 refreshes/min
async refresh(@Body() dto: RefreshDto) { /* ... */ }
@Post('register')
@Throttle({ short: { limit: 2, ttl: 3600_000 } }) // 2 registrations/hour
async register(@Body() dto: RegisterDto) { /* ... */ }
@Post('forgot-password')
@Throttle({ short: { limit: 3, ttl: 3600_000 } }) // 3 resets/hour
async forgotPassword(@Body() dto: ForgotPasswordDto) { /* ... */ }
}
IP-based throttling is not enough
Attackers use distributed IPs. Combine IP-based throttling with account-based throttling (lock after N failures per email) and progressive delays (exponential backoff before each retry).
6. Input Validation with class-validator¶
6.1 DTO Patterns¶
// dto/create-session.dto.ts
import {
IsUUID, IsString, IsOptional, ValidateNested, IsObject,
IsEnum, MaxLength, IsNotEmpty,
} from 'class-validator';
import { Type } from 'class-transformer';
class SessionConfigDto {
@IsString() @MaxLength(50)
model: string;
@IsOptional() @IsObject()
parameters?: Record<string, unknown>;
}
export class CreateSessionDto {
@IsUUID()
containerId: string;
@IsOptional() @IsString() @MaxLength(200)
description?: string;
@IsOptional()
@ValidateNested()
@Type(() => SessionConfigDto)
config?: SessionConfigDto;
}
6.2 Global Validation Pipe¶
// main.ts
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // strip properties not in DTO
forbidNonWhitelisted: true, // throw if unknown properties present
transform: true, // auto-transform payloads to DTO instances
transformOptions: {
enableImplicitConversion: false, // explicit @Type() required
},
}),
);
6.3 Custom Validators¶
// validators/is-safe-string.validator.ts
import { registerDecorator, ValidationOptions } from 'class-validator';
export function IsSafeString(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isSafeString',
target: object.constructor,
propertyName,
options,
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return false;
// Block null bytes, control chars, and common injection patterns
return !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(value);
},
defaultMessage() {
return '$property contains invalid characters';
},
},
});
};
}
// Usage:
export class CreateContainerDto {
@IsString() @Length(3, 63) @Matches(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/)
@IsSafeString()
name: string;
}
Coming from Spring Security
Spring uses @Valid + Bean Validation (JSR 380) with @NotBlank,
@Size, @Pattern. NestJS's class-validator decorators map
almost 1:1: @IsNotEmpty() = @NotBlank, @Length(min, max) =
@Size(min, max), @Matches(regex) = @Pattern(regexp).
7. Security Headers -- Helmet, CORS, CSP¶
7.1 Helmet Configuration¶
// main.ts
import helmet from 'helmet';
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // no inline scripts
styleSrc: ["'self'", "'unsafe-inline'"], // MkDocs needs inline styles
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'wss://vnc.warmwind.ag'], // WebSocket for VNC
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"], // no iframes
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: { policy: 'same-origin' },
crossOriginResourcePolicy: { policy: 'same-origin' },
dnsPrefetchControl: { allow: false },
frameguard: { action: 'deny' },
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
ieNoOpen: true,
noSniff: true,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
xssFilter: true,
}),
);
7.2 CORS Configuration¶
app.enableCors({
origin: [
'https://app.warmwind.ag',
'https://admin.warmwind.ag',
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000'] : []),
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
exposedHeaders: ['X-Total-Count', 'X-Request-Id'],
credentials: true, // allow cookies (refresh token)
maxAge: 86400, // preflight cache: 24h
});
7.3 Response Headers Summary¶
| Header | Value | Purpose |
|---|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload |
Force HTTPS for 1 year |
Content-Security-Policy |
(see above) | Prevent XSS, data injection |
X-Content-Type-Options |
nosniff |
Prevent MIME-type sniffing |
X-Frame-Options |
DENY |
Prevent clickjacking |
Referrer-Policy |
strict-origin-when-cross-origin |
Limit referrer leakage |
Cross-Origin-Opener-Policy |
same-origin |
Isolate browsing context |
8. Password Hashing -- Argon2id¶
8.1 Why Argon2id?¶
| Algorithm | Memory-Hard | GPU-Resistant | Side-Channel Resistant | Recommended |
|---|---|---|---|---|
| bcrypt | Partial | Partial | No | Legacy |
| scrypt | Yes | Yes | No | Acceptable |
| Argon2d | Yes | Yes | No | Not for passwords |
| Argon2i | Yes | Partial | Yes | Not ideal |
| Argon2id | Yes | Yes | Yes | OWASP default |
Argon2id combines Argon2d (data-dependent memory access, GPU-resistant) with Argon2i (data-independent, side-channel resistant) -- hybrid protection.
8.2 Implementation¶
// auth/password.service.ts
import * as argon2 from 'argon2';
@Injectable()
export class PasswordService {
// OWASP 2024 recommended minimum: 19 MiB memory, 2 iterations, 1 parallelism
// We use slightly stronger settings
private readonly hashOptions: argon2.Options = {
type: argon2.argon2id,
memoryCost: 65536, // 64 MiB -- tune per server (AWS t3.medium: 64 MiB is safe)
timeCost: 3, // 3 iterations
parallelism: 1, // 1 thread (avoid contention under load)
hashLength: 32, // 256-bit output
};
async hash(password: string): Promise<string> {
return argon2.hash(password, this.hashOptions);
}
async verify(storedHash: string, inputPassword: string): Promise<boolean> {
try {
return await argon2.verify(storedHash, inputPassword);
} catch {
return false; // malformed hash, treat as mismatch
}
}
needsRehash(storedHash: string): boolean {
return argon2.needsRehash(storedHash, this.hashOptions);
}
}
8.3 Rehash on Login (Parameter Migration)¶
// auth/auth.service.ts (in login method)
const valid = await this.passwords.verify(user.passwordHash, password);
if (!valid) throw new UnauthorizedException('Invalid credentials');
// Transparently upgrade hash parameters
if (this.passwords.needsRehash(user.passwordHash)) {
const newHash = await this.passwords.hash(password);
await this.users.updatePasswordHash(user.id, newHash);
}
Coming from Spring Security
Spring Security's DelegatingPasswordEncoder handles multi-algorithm
hashes via {argon2}, {bcrypt} prefixes and auto-upgrades on login.
The needsRehash() + re-hash-on-login pattern above is the manual
equivalent in NestJS.
9. API Key Management¶
9.1 When to Use API Keys vs JWT¶
| Use Case | Mechanism | Lifetime |
|---|---|---|
| Human user in browser | JWT (access + refresh) | 15m / 7d |
| Server-to-server (CI/CD, webhooks) | API key | Long-lived, manually rotated |
| Service mesh internal | mTLS or JWT with short expiry | Auto-rotated |
9.2 API Key Generation and Storage¶
// auth/api-key.service.ts
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class ApiKeyService {
constructor(
@InjectRepository(ApiKey) private repo: Repository<ApiKey>,
) {}
async generate(userId: string, name: string): Promise<{ key: string; prefix: string }> {
// Generate a 32-byte random key, encode as base64url
const rawKey = randomBytes(32).toString('base64url');
const prefix = rawKey.slice(0, 8); // first 8 chars for identification
// Store only the SHA-256 hash -- never store the raw key
const hash = createHash('sha256').update(rawKey).digest('hex');
await this.repo.save({
prefix,
keyHash: hash,
userId,
name,
lastUsedAt: null,
});
// Return raw key to user ONCE -- cannot be recovered
return { key: `ww_${rawKey}`, prefix };
}
async validate(apiKey: string): Promise<ApiKey | null> {
if (!apiKey.startsWith('ww_')) return null;
const rawKey = apiKey.slice(3);
const hash = createHash('sha256').update(rawKey).digest('hex');
const stored = await this.repo.findOne({
where: { keyHash: hash, revoked: false },
relations: { user: true },
});
if (stored) {
// Update last used timestamp (fire-and-forget)
this.repo.update(stored.id, { lastUsedAt: new Date() }).catch(() => {});
}
return stored;
}
async revoke(id: string, userId: string): Promise<void> {
await this.repo.update({ id, userId }, { revoked: true });
}
}
9.3 API Key Guard¶
// auth/guards/api-key.guard.ts
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private apiKeyService: ApiKeyService) {}
async canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const apiKey = request.headers['x-api-key'];
if (!apiKey) return false;
const stored = await this.apiKeyService.validate(apiKey);
if (!stored) return false;
// Attach user to request, same shape as JWT
request.user = {
id: stored.user.id,
email: stored.user.email,
tenantId: stored.user.tenantId,
roles: stored.user.roles,
};
return true;
}
}
// Usage: @UseGuards(JwtOrApiKeyGuard) -- tries JWT first, falls back to API key
10. Putting It All Together -- Middleware Stack¶
flowchart TD
REQ["Incoming Request"] --> HELMET["Helmet (security headers)"]
HELMET --> CORS["CORS check"]
CORS --> RATE["Rate Limiter (ThrottlerGuard)"]
RATE --> VALIDATE["ValidationPipe (body/params)"]
VALIDATE --> AUTH{"Auth Guard"}
AUTH -->|JWT| JWT_STRAT["JwtStrategy.validate()"]
AUTH -->|API Key| KEY_STRAT["ApiKeyGuard.canActivate()"]
AUTH -->|OIDC| OIDC_STRAT["OidcStrategy.validate()"]
JWT_STRAT --> AUTHZ["PoliciesGuard (CASL)"]
KEY_STRAT --> AUTHZ
OIDC_STRAT --> AUTHZ
AUTHZ --> TENANT["TenantMiddleware (SET app.current_tenant)"]
TENANT --> HANDLER["Route Handler"]
HANDLER --> RES["Response"]
Glossary¶
Glossary
- JWT (JSON Web Token)
- Compact, URL-safe token containing signed JSON claims. The access token is short-lived (15m) and stateless -- the server validates the signature without a database lookup. The trade-off: you cannot revoke an access token before expiry without a blocklist.
- Refresh Token Rotation
- Each refresh token is single-use. Exchanging it produces a new access + refresh pair and invalidates the old token. Reuse of an old token triggers revocation of the entire token family.
- Token Family
- A lineage of refresh tokens sharing a common ancestor. If any token in the family is replayed, the entire family is revoked -- bounding the damage window of a stolen token.
- PKCE (Proof Key for Code Exchange)
- OAuth2 extension (RFC 7636). The client generates a random
code_verifier, sends its SHA-256 hash (code_challenge) in the authorization request, and proves possession by sending the original verifier when exchanging the authorization code. - CASL
- Isomorphic authorization library for JavaScript. Defines abilities as
can(action, subject, conditions)rules -- more expressive than simple role checks because it supports attribute-based conditions. - RBAC (Role-Based Access Control)
- Permissions assigned to roles (admin, operator, viewer); users assigned roles. Simpler than ABAC but less granular. CASL bridges the gap by adding conditions to role-based rules.
- Argon2id
- Password hashing algorithm combining Argon2d (data-dependent memory access for GPU resistance) and Argon2i (data-independent access for side-channel resistance). OWASP recommended default since 2024.
- Content Security Policy (CSP)
- HTTP response header that restricts which sources the browser can load scripts, styles, images, and connections from. Primary defense against XSS.
- HSTS (HTTP Strict Transport Security)
- Header instructing the browser to only use HTTPS for the domain. With
preload, the domain is hard-coded into browser HTTPS lists -- even the first visit is secure. - ValidationPipe
- NestJS pipe that uses
class-validatordecorators to validate incoming request payloads.whitelist: truestrips unknown properties;forbidNonWhitelisted: truerejects them. - API Key
- Long-lived credential for server-to-server authentication. Stored as a
SHA-256 hash in the database. The raw key is shown once at creation and
cannot be recovered. Prefixed with
ww_for identification. - mTLS (Mutual TLS)
- Both client and server present certificates during the TLS handshake. Used for service-to-service authentication in zero-trust networks.