Skip to content

Commit 0d3cdd7

Browse files
committed
feat: Introduce AuthModule for handling authorization and authentication through OIDC
1 parent ceae6bc commit 0d3cdd7

33 files changed

+1040
-91
lines changed

apps/nest/src/app.controller.test.ts

+29-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { AppController } from './app.controller';
2-
import { AppService } from './app.service';
32
import { TestContainer, Supertest } from '@spuxx/nest-utils';
3+
import { authConfig, AuthRole } from './auth/auth.config';
44

55
describe('AppController', () => {
66
let supertest: Supertest;
77

88
beforeEach(async () => {
99
const container = await TestContainer.create({
1010
controllers: [AppController],
11-
providers: [AppService],
11+
authOptions: authConfig,
1212
enableEndToEnd: true,
1313
});
1414
supertest = container.supertest;
@@ -18,7 +18,33 @@ describe('AppController', () => {
1818
it('should be successful', async () => {
1919
const response = await supertest.get('/');
2020
expect(response.statusCode).toBe(200);
21-
expect(response.text).toContain('Hello!');
21+
expect(response.body.message).toBe('Hello there!');
22+
});
23+
});
24+
25+
describe('protected', () => {
26+
it('should be successful', async () => {
27+
const response = await supertest.get('/protected', {
28+
session: {
29+
sub: '123',
30+
realm_access: { roles: [AuthRole.user] },
31+
},
32+
});
33+
expect(response.statusCode).toBe(200);
34+
});
35+
36+
it('should return 401', async () => {
37+
const response = await supertest.get('/protected');
38+
expect(response.statusCode).toBe(401);
39+
});
40+
41+
it('should return 403', async () => {
42+
const response = await supertest.get('/protected', {
43+
session: {
44+
sub: '123',
45+
},
46+
});
47+
expect(response.statusCode).toBe(403);
2248
});
2349
});
2450
});

apps/nest/src/app.controller.ts

+26-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
1-
import { Controller, Get } from '@nestjs/common';
2-
import { AppService } from './app.service';
1+
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
2+
import { EnvModule } from './env/env.module';
3+
import type { Request } from 'express';
4+
import { AuthGuard, Roles } from 'packages/nest-utils/dist/main';
5+
import { AuthRole } from './auth/auth.config';
36

47
@Controller()
58
export class AppController {
6-
constructor(private readonly service: AppService) {}
7-
89
@Get()
9-
getHello(): string {
10-
return this.service.getHello();
10+
getHello(@Req() request: Request): object {
11+
const response = {
12+
message: 'Hello there!',
13+
time: new Date().toLocaleTimeString(),
14+
session: request.oidc.user ? `Logged in as ${request.oidc.user.name}` : 'Not logged in',
15+
routes: {
16+
auth: {
17+
login: `${EnvModule.get('APP_BASE_URL')}/auth/login`,
18+
logout: `${EnvModule.get('APP_BASE_URL')}/auth/logout`,
19+
session: `${EnvModule.get('APP_BASE_URL')}/auth/session`,
20+
},
21+
},
22+
};
23+
return response;
24+
}
25+
26+
@Get('/protected')
27+
@UseGuards(AuthGuard)
28+
@Roles(AuthRole.user)
29+
getProtectedHello(@Req() request: Request) {
30+
return `Oh hello there, ${request.oidc.user.name}! This route is protected, but you can see it! Not bad!`;
1131
}
1232
}

apps/nest/src/app.module.test.ts

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { TestContainer } from 'packages/nest-utils/dist/main';
2+
import { AppModule } from './app.module';
3+
4+
describe('AppModule', () => {
5+
it('should be ok', async () => {
6+
vitest.stubEnv('AUTH_CLIENT_SECRET', 'something');
7+
const container = await TestContainer.create({
8+
imports: [AppModule],
9+
});
10+
expect(container.module).toBeDefined();
11+
});
12+
});

apps/nest/src/app.module.ts

+3-5
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import { Module } from '@nestjs/common';
22
import { AppController } from './app.controller';
3-
import { AppService } from './app.service';
3+
import { AuthModule } from '@spuxx/nest-utils';
44
import { EnvModule } from './env/env.module';
5+
import { authConfig } from './auth/auth.config';
56

67
@Module({
7-
imports: [
8-
EnvModule,
9-
],
8+
imports: [EnvModule, AuthModule.forRoot(authConfig)],
109
controllers: [AppController],
11-
providers: [AppService],
1210
})
1311
export class AppModule {}

apps/nest/src/app.service.ts

-9
This file was deleted.

apps/nest/src/auth/auth.config.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { AuthOptions } from 'packages/nest-utils/dist/main';
2+
import { EnvModule } from '../env/env.module';
3+
4+
export const AuthRole = {
5+
user: 'test_user',
6+
} as const;
7+
export type AuthRole = (typeof AuthRole)[keyof typeof AuthRole];
8+
export const authRoles = Object.values(AuthRole);
9+
10+
export const authConfig: AuthOptions = {
11+
disable: false,
12+
roles: AuthRole,
13+
oidc: {
14+
baseURL: EnvModule.get('APP_BASE_URL'),
15+
issuerBaseURL: EnvModule.get('AUTH_ISSUER_URL'),
16+
clientID: EnvModule.get('AUTH_CLIENT_ID'),
17+
clientSecret: EnvModule.get('AUTH_CLIENT_SECRET'),
18+
secret: EnvModule.get('AUTH_CLIENT_SECRET'),
19+
},
20+
};

apps/nest/src/auth/config/auth.config.ts

Whitespace-only changes.

apps/nest/src/env/env.definiton.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1-
import { IsDate, IsString } from 'class-validator';
1+
import { IsIn, IsString, IsUrl } from 'class-validator';
2+
import { ApplicationLogLevel } from 'packages/nest-utils/dist/main';
23

34
export class Environment {
45
@IsString()
5-
APP_NAME: string = 'nest';
6+
@IsIn(Object.values(ApplicationLogLevel))
7+
APP_LOG_LEVEL: ApplicationLogLevel = ApplicationLogLevel.Default;
68

7-
@IsDate()
8-
START_TIME: Date;
9+
@IsString()
10+
APP_BASE_URL: string = 'http://localhost:3000';
11+
12+
@IsUrl()
13+
AUTH_ISSUER_URL: string = 'https://auth.spuxx.dev/realms/spuxx/';
14+
15+
@IsString()
16+
AUTH_CLIENT_ID: string = 'test';
17+
18+
@IsString()
19+
AUTH_CLIENT_SECRET: string;
920
}

apps/nest/src/main.ts

+16-8
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
1+
import { Logger } from '@nestjs/common';
12
import { NestFactory } from '@nestjs/core';
3+
import { CustomLogger, AuthModule } from '@spuxx/nest-utils';
24
import { AppModule } from './app.module';
3-
import { Logger } from '@nestjs/common';
5+
import { authConfig } from './auth/auth.config';
6+
import { EnvModule } from './env/env.module';
47

58
async function bootstrap() {
6-
const app = await NestFactory.create(AppModule);
9+
const logger = new CustomLogger({
10+
logLevel: EnvModule.get('APP_LOG_LEVEL'),
11+
});
12+
const app = await NestFactory.create(AppModule, {
13+
logger,
14+
});
15+
16+
AuthModule.bootstrap(app, authConfig);
17+
18+
await app.listen(3000);
719
Logger.log(`Application is running on: http://localhost:3000`, 'Bootstrap');
8-
// Required, see: https://www.npmjs.com/package/vite-plugin-node#get-started
9-
if (import.meta.env.PROD) {
10-
await app.listen(3000);
11-
}
12-
return app;
20+
Logger.verbose('Verbose logging is enabled.', 'Bootstrap');
1321
}
1422

15-
export const app = bootstrap();
23+
bootstrap();

packages/nest-utils/package.json

+4-2
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,21 @@
4646
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
4747
"@nestjs/config": "^3.0.0",
4848
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0",
49+
"@nestjs/swagger": "^7.0.0",
4950
"@nestjs/testing": "^8.0.0 || ^9.0.0 || ^10.0.0",
5051
"class-transformer": "^0.5.0",
5152
"class-validator": "^0.14.0",
53+
"express-openid-connect": "^2.17.1",
5254
"reflect-metadata": "^0.1.13 || ^0.2.0",
53-
"supertest": "^7.0.0",
54-
"express-openid-connect": "^2.17.1"
55+
"supertest": "^7.0.0"
5556
},
5657
"peerDependenciesMeta": {
5758
"express-openid-connect": {
5859
"optional": true
5960
}
6061
},
6162
"dependencies": {
63+
"@nanogiants/nestjs-swagger-api-exception-decorator": "^1.6.11",
6264
"@spuxx/js-utils": "workspace:@spuxx/js-utils@*"
6365
}
6466
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { DynamicModule, INestApplication, Logger, Module } from '@nestjs/common';
2+
import { defaultAuthOptions, type AuthOptions } from '../main';
3+
import { auth } from 'express-openid-connect';
4+
import { AuthController } from './controllers/auth.controller';
5+
import { AuthService } from './providers/auth.service';
6+
import { deepMerge } from 'packages/js-utils/dist/main';
7+
import { AuthOptionsProvider } from './providers/auth-options.provider';
8+
9+
/**
10+
* The authentication module. This module is responsible for handling authentication and
11+
* authorization. It is based on the `express-openid-connect?` library and is intended
12+
* for use with an OIDC provider.
13+
* @example
14+
* // main.ts
15+
* import { AuthModule, AuthOptions } from '@nestjs-oidc/core';
16+
* const authConfig: AuthOptions = {
17+
* // This is the minimum set of options you need to provide
18+
* roles: {
19+
* admin: "admin",
20+
* user: "user",
21+
* // ... more roles ...
22+
* },
23+
* oidc: {
24+
* issuerBaseURL: 'https://example.com',
25+
* baseURL: 'http://localhost:3000',
26+
* clientID: 'client-id',
27+
* clientSecret: 'client-secret',
28+
* secret: 'session-secret',
29+
* }
30+
* }
31+
* await AuthModule.bootstrap(app, authConfig);
32+
*
33+
* // app.module.ts
34+
* import { AuthModule } from '@nestjs-oidc/core';
35+
* @Module({
36+
* imports: [AuthModule.forRoot(authConfig)],
37+
* })
38+
* export class AppModule {}
39+
*/
40+
@Module({})
41+
export class AuthModule {
42+
/**
43+
* Bootstraps authentication. This must be called during application bootstrapping.
44+
* @param app The Nest application instance.
45+
* @param options The authentication options.
46+
*/
47+
static async bootstrap(app: INestApplication, options: AuthOptions) {
48+
const mergedOptions = this.mergeOptionsWithDefaultValues(options);
49+
const { disable, oidc } = mergedOptions;
50+
if (disable) {
51+
Logger.warn('Authentication is disabled. All routes will be accessible.', AuthModule.name);
52+
return;
53+
}
54+
app.use(auth(oidc));
55+
Logger.log(`Authentication is enabled and will be handled by issuer at '${oidc.issuerBaseURL}'.`, AuthModule.name);
56+
}
57+
58+
static forRoot(options: AuthOptions): DynamicModule {
59+
return {
60+
module: AuthModule,
61+
controllers: [AuthController],
62+
providers: [
63+
AuthService,
64+
{
65+
provide: AuthOptionsProvider,
66+
useValue: new AuthOptionsProvider(this.mergeOptionsWithDefaultValues(options)),
67+
},
68+
],
69+
exports: [AuthService, AuthOptionsProvider],
70+
};
71+
}
72+
73+
static mergeOptionsWithDefaultValues(options: AuthOptions) {
74+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
75+
const mergedOptions = deepMerge(defaultAuthOptions as any, options as any) as unknown as AuthOptions;
76+
return mergedOptions;
77+
}
78+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
2+
3+
export const authExceptions = {
4+
login: {
5+
badRequest: new BadRequestException(),
6+
forbiddenReturnTo: new BadRequestException(
7+
"The value of 'returnTo' is not allowed. Redirect URLs must match the allowed CORS origins or specific application endpoints. The URL must be absolute!",
8+
),
9+
urlParsingError: new BadRequestException('An error occurred while parsing the redirect URL.'),
10+
},
11+
session: {
12+
unauthorized: new UnauthorizedException(),
13+
},
14+
logout: {
15+
badRequest: new BadRequestException(),
16+
forbiddenReturnTo: new BadRequestException(
17+
"The value of 'returnTo' is not allowed. Redirect URLs must match the allowed CORS origins or specific application endpoints.",
18+
),
19+
},
20+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
2+
import { ConfigParams as ExpressOidcConfig } from 'express-openid-connect';
3+
4+
export interface AuthOptions {
5+
/**
6+
* Whether authentication should be disabled.
7+
* @default false
8+
*/
9+
disable?: boolean;
10+
/**
11+
* The list of external URLs the service is allowed to redirect to after login or logout.
12+
* Local redirects are always allowed.
13+
* @default []
14+
*/
15+
allowedRedirectUrls?: string[];
16+
/**
17+
* The URL that should be used as the default redirect URL after login or logout.
18+
* @default 'auth/session'
19+
*/
20+
defaultRedirectUrl?: string;
21+
/**
22+
* The record of roles available in the application.
23+
*/
24+
roles?: Record<string, string>;
25+
/**
26+
* Configuration parameters passed to `express-openid-connect`. For documentation, see:
27+
* https://auth0.github.io/express-openid-connect/interfaces/ConfigParams.html
28+
*/
29+
oidc: ExpressOidcConfig;
30+
}
31+
32+
type DefaultAuthOptions = Partial<Omit<AuthOptions, 'oidc'>> & Pick<AuthOptions, 'oidc'>;
33+
34+
/**
35+
* Default values for the authentication options.
36+
*/
37+
export const defaultAuthOptions: DefaultAuthOptions = {
38+
disable: false,
39+
allowedRedirectUrls: [],
40+
defaultRedirectUrl: '/',
41+
oidc: {
42+
errorOnRequiredAuth: true,
43+
authRequired: false,
44+
auth0Logout: false,
45+
idpLogout: true,
46+
enableTelemetry: false,
47+
authorizationParams: {
48+
response_type: 'code',
49+
response_mode: 'query',
50+
scope: 'openid profile email roles',
51+
},
52+
session: {
53+
name: `${process.env.npm_package_name}-session`,
54+
},
55+
routes: {
56+
login: false, // Is overwritten in AuthController
57+
logout: false, // Is overwritten in AuthController
58+
callback: '/auth/callback',
59+
},
60+
},
61+
};

0 commit comments

Comments
 (0)