Skip to main content

Authentication & Authorization

Overview​

The application implements JWT-based authentication with Azure Entra ID integration for enterprise SSO. This guide covers both token-based authentication for API clients and federated identity for enterprise users.

Authentication Strategies​

JWT Authentication​

JWT (JSON Web Tokens) provide stateless authentication:

  • Token-Based: No session storage on server
  • Stateless: Verify signature without database lookup
  • Scalable: Works across multiple server instances
  • Expirable: Tokens have built-in expiration

Azure Entra ID​

Azure Entra ID (formerly Azure AD) provides:

  • Enterprise SSO: Single sign-on for organization
  • Federated Identity: Delegate auth to Microsoft
  • Multi-Tenant: Support multiple organizations
  • Compliance: Enterprise security standards

JWT Implementation​

JWT Strategy Setup​

src/auth/strategies/jwt.strategy.ts​

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

interface JwtPayload {
sub: string;
email: string;
roles: string[];
iat: number;
exp: number;
}

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get('JWT_ACCESS_SECRET'),
});
}

validate(payload: JwtPayload) {
return {
userId: payload.sub,
email: payload.email,
roles: payload.roles,
};
}
}

Auth Service​

src/auth/auth.service.ts​

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private prisma: PrismaService,
) {}

async login(email: string, password: string) {
// Find user
const user = await this.prisma.user.findUnique({
where: { email },
});

if (!user || !user.passwordHash) {
throw new UnauthorizedException('Invalid credentials');
}

// Verify password
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}

// Generate tokens
return this.generateTokens(user.id, user.email, user.roles);
}

async generateTokens(userId: string, email: string, roles: string[]) {
const accessToken = this.jwtService.sign(
{
sub: userId,
email,
roles,
},
{
expiresIn: this.configService.get('JWT_ACCESS_EXPIRES_IN', '15m'),
},
);

const refreshToken = this.jwtService.sign(
{
sub: userId,
type: 'refresh',
},
{
expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN', '7d'),
},
);

// Store refresh token in database
await this.prisma.userSession.create({
data: {
userId,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});

return {
accessToken,
refreshToken,
expiresIn: 900, // 15 minutes in seconds
};
}

async refreshTokens(refreshToken: string) {
// Verify token
const payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
});

// Check if token still valid in database
const session = await this.prisma.userSession.findUnique({
where: { token: refreshToken },
});

if (!session || session.expiresAt < new Date()) {
throw new UnauthorizedException('Refresh token expired');
}

// Get fresh user data
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});

if (!user) {
throw new UnauthorizedException('User not found');
}

return this.generateTokens(user.id, user.email, user.roles);
}

async logout(refreshToken: string) {
await this.prisma.userSession.delete({
where: { token: refreshToken },
});
}
}

Auth Controller​

src/auth/auth.controller.ts​

import { Controller, Post, Body, UseGuards, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ZodValidationPipe } from 'nestjs-zod';
import { AuthService } from './auth.service';
import { LoginSchema } from '@qms/schemas/auth/login.schema';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { RefreshTokenGuard } from './guards/refresh-token.guard';

@Controller('auth')
@ApiTags('Authentication')
export class AuthController {
constructor(private authService: AuthService) {}

@Post('login')
@HttpCode(HttpStatus.OK)
async login(
@Body(new ZodValidationPipe(LoginSchema))
{ email, password }: LoginSchema,
) {
const tokens = await this.authService.login(email, password);
return {
data: tokens,
};
}

@Post('refresh')
@HttpCode(HttpStatus.OK)
@UseGuards(RefreshTokenGuard)
async refresh(@Request() req) {
const tokens = await this.authService.refreshTokens(req.user.token);
return {
data: tokens,
};
}

@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtAuthGuard)
async logout(@Request() req) {
await this.authService.logout(req.user.refreshToken);
}

@Post('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async getProfile(@Request() req) {
return {
data: {
userId: req.user.userId,
email: req.user.email,
roles: req.user.roles,
},
};
}
}

Azure Entra ID Integration​

Azure Configuration​

Environment Variables​

AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
AZURE_REDIRECT_URI=http://localhost:3001/auth/azure/callback

Azure Strategy​

src/auth/strategies/azure.strategy.ts​

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { BearerStrategy } from 'passport-azure-ad';
import { ConfigService } from '@nestjs/config';

interface AzureAdProfile {
oid: string;
name: string;
email: string;
upn: string;
}

@Injectable()
export class AzureAdStrategy extends PassportStrategy(BearerStrategy, 'azure-ad') {
constructor(configService: ConfigService) {
super({
identityMetadata: `https://login.microsoftonline.com/${configService.get('AZURE_TENANT_ID')}/v2.0/.well-known/openid-configuration`,
clientID: configService.get('AZURE_CLIENT_ID'),
audience: configService.get('AZURE_CLIENT_ID'),
});
}

validate(payload: AzureAdProfile) {
return {
userId: payload.oid,
email: payload.email,
name: payload.name,
upn: payload.upn,
};
}
}

Azure Authentication Route​

src/auth/auth.controller.ts (extended)​

@Post('azure/login')
@UseGuards(AuthGuard('azure-ad'))
@HttpCode(HttpStatus.OK)
async azureLogin(@Request() req) {
// User authenticated by Azure Strategy
const azureUser = req.user;

// Get or create user in database
let user = await this.prisma.user.findUnique({
where: { email: azureUser.email },
});

if (!user) {
user = await this.prisma.user.create({
data: {
email: azureUser.email,
name: azureUser.name,
azureId: azureUser.userId,
authProvider: 'azure',
},
});
}

// Generate JWT tokens
const tokens = await this.authService.generateTokens(
user.id,
user.email,
user.roles,
);

return {
data: tokens,
};
}

Guards​

JWT Auth Guard​

src/auth/guards/jwt-auth.guard.ts​

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

Refresh Token Guard​

src/auth/guards/refresh-token.guard.ts​

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}

Optional Auth Guard​

@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err, user, info, context) {
// Don't throw if no auth - just return user or undefined
return user || null;
}
}

Authorization (RBAC)​

Role-Based Access Control​

import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';

@Injectable()
export class RolesGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const requiredRoles = Reflect.getMetadata('roles', context.getHandler());

if (!requiredRoles) {
return true;
}

const userRoles = request.user?.roles || [];
const hasRole = requiredRoles.some((role: string) => userRoles.includes(role));

if (!hasRole) {
throw new ForbiddenException('You do not have permission to access this resource');
}

return true;
}
}

Roles Decorator​

import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

Usage in Controller​

@Get('admin-only')
@Roles('admin', 'manager')
@UseGuards(JwtAuthGuard, RolesGuard)
async adminEndpoint() {
return { data: 'Admin data' };
}

Permissions (PBAC)​

Permission-Based Access Control​

@Injectable()
export class PermissionsGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const requiredPermissions = Reflect.getMetadata('permissions', context.getHandler());

if (!requiredPermissions) {
return true;
}

const userPermissions = request.user?.permissions || [];
const hasPermission = requiredPermissions.every((permission: string) =>
userPermissions.includes(permission),
);

if (!hasPermission) {
throw new ForbiddenException('Insufficient permissions');
}

return true;
}
}

export const Permissions = (...permissions: string[]) => SetMetadata('permissions', permissions);

Token Validation​

Token Expiration​

interface JwtPayload {
sub: string;
email: string;
iat: number;
exp: number;
}

// JWT automatically validates expiration
// If expired, throws UnauthorizedException
const payload = await jwtService.verifyAsync(token);

Token Revocation​

// Store revoked tokens
await prisma.revokedToken.create({
data: {
token,
revokedAt: new Date(),
expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
},
});

// Check if revoked before accepting
const revoked = await prisma.revokedToken.findUnique({
where: { token },
});

if (revoked) {
throw new UnauthorizedException('Token has been revoked');
}

Auth Module​

Complete Auth Module​

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';
import { AzureAdStrategy } from './strategies/azure.strategy';

@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
global: true,
useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_ACCESS_SECRET'),
signOptions: { expiresIn: '15m' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, AzureAdStrategy],
exports: [AuthService],
})
export class AuthModule {}

Schemas​

Login Schema​

export const LoginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});

export type LoginRequest = z.infer<typeof LoginSchema>;

Environment Configuration​

# JWT
JWT_ACCESS_SECRET=your-secret-key-min-32-chars
JWT_REFRESH_SECRET=your-refresh-secret-min-32-chars
JWT_ACCESS_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d

# Azure
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret

Best Practices​

1. Use Short-Lived Access Tokens​

// βœ… GOOD: 15 minute access token
expiresIn: '15m';

// ❌ AVOID: Long-lived access token
expiresIn: '30d';

2. Implement Token Rotation​

// Refresh tokens have longer lifetime
// But can be revoked
// Force re-authentication periodically

3. Secure Token Transmission​

// βœ… GOOD: Bearer token in Authorization header
Authorization: Bearer<token>;

// Transmitted over HTTPS only
// Never in URL parameters

4. Validate All Tokens​

// βœ… GOOD: Verify and validate all tokens
const payload = await jwtService.verifyAsync(token);

// ❌ AVOID: Assume token is valid
const payload = jwt.decode(token);

Troubleshooting​

Token Verification Fails​

# Check secret key matches
echo $JWT_ACCESS_SECRET

# Verify token format
# Bearer <token>

# Check token expiration

Azure Integration Issues​

# Verify tenant ID
# Verify client ID
# Check redirect URI matches registered app

# Test with curl
curl -X POST \
-H "Authorization: Bearer <token>" \
http://localhost:3001/auth/profile

Resources​