A reusable NestJS module for OIDC (OpenID Connect) authentication in Single Page Applications. This module provides seamless integration with OIDC providers using JWT tokens, with built-in support for authentication guards, role-based access control, and platform-agnostic request handling.
- ✅ OIDC/JWT Token Validation - Automatic token verification using
oidc-spa/backend - ✅ Global Authentication Guard - Protect all routes by default with opt-out via
@Public()decorator - ✅ Role-Based Access Control (RBAC) - Easy role checking with
@Roles()decorator - ✅ Platform Agnostic - Works with both Express and Fastify
- ✅ TypeScript First - Full type safety with customizable token schemas
- ✅ Flexible Configuration - Synchronous and asynchronous configuration options
- ✅ Custom Logger Support - Bring your own logger or use the built-in console logger
- ✅ User Context Extraction - Simple
@User()decorator to access authenticated user
pnpm add @mwolf1989/nestjs-spa-oidc
# or
npm install @mwolf1989/nestjs-spa-oidc
# or
yarn add @mwolf1989/nestjs-spa-oidc| NestJS | Node.js | Status |
|---|---|---|
| 11.x | >=20.19 | Supported and tested |
| 12.x | >=20.19 | Supported and tested |
NestJS 12 ships its core packages as ESM. This package remains compatible with both CommonJS and ESM consumers by following the NestJS 12 Node.js runtime requirement.
import { Module } from '@nestjs/common';
import { OidcSpaModule } from '@mwolf1989/nestjs-spa-oidc';
@Module({
imports: [
OidcSpaModule.forRoot({
issuerUri: 'https://auth.example.com/realms/myrealm',
audience: 'account',
trustProxy: true,
}),
],
})
export class AppModule {}The global authentication guard passes the complete HTTP request to oidc-spa/server, enabling both Bearer and DPoP validation. trustProxy defaults to true; set it to false when the application is reached directly without a trusted reverse proxy.
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { OidcSpaModule } from '@mwolf1989/nestjs-spa-oidc';
@Module({
imports: [
ConfigModule.forRoot(),
OidcSpaModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
issuerUri: configService.get('OIDC_ISSUER_URI'),
audience: configService.get('OIDC_AUDIENCE'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}import { Controller, Get } from '@nestjs/common';
import { Public, User, Roles } from '@mwolf1989/nestjs-spa-oidc';
import type { DefaultDecodedAccessToken } from '@mwolf1989/nestjs-spa-oidc';
@Controller('api')
export class ApiController {
// Public route - no authentication required
@Public()
@Get('health')
getHealth() {
return { status: 'ok' };
}
// Protected route - requires authentication
@Get('profile')
getProfile(@User() user: DefaultDecodedAccessToken) {
return {
userId: user.sub,
roles: user.realm_access?.roles || [],
};
}
// Role-protected route - requires specific role
@Roles('admin')
@Get('admin')
getAdminData(@User() user: DefaultDecodedAccessToken) {
return { message: 'Admin only data' };
}
// Multiple roles - user needs at least one
@Roles('admin', 'moderator')
@Get('moderation')
getModerationData() {
return { message: 'Moderation data' };
}
// Extract specific user property
@Get('user-id')
getUserId(@User('sub') userId: string) {
return { userId };
}
}Define your own token structure with Zod:
import { z } from 'zod';
import { OidcSpaModule } from '@mwolf1989/nestjs-spa-oidc';
const CustomTokenSchema = z.object({
sub: z.string(),
aud: z.union([z.string(), z.array(z.string())]),
email: z.string().email(),
name: z.string(),
roles: z.array(z.string()),
organization: z.string().optional(),
});
type CustomToken = z.infer<typeof CustomTokenSchema>;
@Module({
imports: [
OidcSpaModule.forRoot<CustomToken>({
issuerUri: 'https://auth.example.com',
audience: 'my-api',
decodedAccessTokenSchema: CustomTokenSchema,
getRolesFromDecodedAccessToken: (token) => token.roles, // Custom role extraction
}),
],
})
export class AppModule {}Integrate with your existing logger (e.g., Pino, Winston):
import { Logger } from 'nestjs-pino';
import { OidcSpaModule, OidcLogger } from '@mwolf1989/nestjs-spa-oidc';
class PinoOidcLogger implements OidcLogger {
constructor(private readonly logger: Logger) {}
log(message: string, context?: string): void {
this.logger.log({ context }, message);
}
error(message: string, trace?: string, context?: string): void {
this.logger.error({ context, trace }, message);
}
warn(message: string, context?: string): void {
this.logger.warn({ context }, message);
}
debug(message: string, context?: string): void {
this.logger.debug({ context }, message);
}
}
@Module({
imports: [
OidcSpaModule.forRootAsync({
imports: [LoggerModule],
useFactory: (logger: Logger) => ({
issuerUri: process.env.OIDC_ISSUER_URI,
audience: process.env.OIDC_AUDIENCE,
logger: new PinoOidcLogger(logger),
}),
inject: [Logger],
}),
],
})
export class AppModule {}If you prefer to apply guards manually:
OidcSpaModule.forRoot({
issuerUri: 'https://auth.example.com',
audience: 'account',
globalGuard: false, // Disable global AuthGuard
globalRolesGuard: false, // Disable global RolesGuard
})Then apply guards manually in controllers:
import { Controller, UseGuards } from '@nestjs/common';
import { AuthGuard, RolesGuard } from '@mwolf1989/nestjs-spa-oidc';
@Controller('api')
@UseGuards(AuthGuard, RolesGuard)
export class ApiController {
// Your routes here
}| Option | Type | Required | Default | Description |
|---|---|---|---|---|
issuerUri |
string |
Yes | - | OIDC issuer URI (e.g., https://auth.example.com/realms/myrealm) |
audience |
string |
Yes | - | Expected audience for the access token |
trustProxy |
boolean |
No | true |
Trust forwarded host/protocol headers when validating DPoP requests |
decodedAccessTokenSchema |
ZodType |
No | DefaultDecodedAccessTokenSchema |
Zod schema for token validation |
globalGuard |
boolean |
No | true |
Apply AuthGuard globally |
globalRolesGuard |
boolean |
No | true |
Apply RolesGuard globally |
logger |
OidcLogger |
No | ConsoleLogger |
Custom logger instance |
getRolesFromDecodedAccessToken |
function |
No | (token) => token.realm_access?.roles || [] |
Function to extract roles from token |
@Public()- Mark a route as public (no authentication required)@User(property?)- Extract authenticated user from request@Roles(...roles)- Require specific roles for route access
AuthGuard- Validates JWT tokens and attaches user to requestRolesGuard- Checks if user has required roles
OidcService- Core service for token validation and user info extractiondecodeRequest(request, requiredRole?)- Decode and validate Bearer or DPoP authentication from a complete requestdecodeAccessToken(authHeader, requiredRole?)- Decode and validate a Bearer token when only the header is availablegetUserId(token)- Extract user ID from tokengetUserRoles(token)- Extract roles from tokenhasRole(token, role)- Check if user has specific role
OIDC_ISSUER_URI=https://auth.example.com/realms/myrealm
OIDC_AUDIENCE=accountMIT
Contributions are welcome! Please feel free to submit a Pull Request.