From 75910e377d2d3e6f3bf4da5527f7e47930b715a3 Mon Sep 17 00:00:00 2001 From: Nicolas Drolet Date: Thu, 24 Jul 2025 16:22:26 -0400 Subject: [PATCH] feat(Oauth2): implement full Oauth2 authentication handshake with Authentik IdP. Authorization (authentik-auth.service) is disconnected for now. --- docs/swagger/swagger-spec.json | 28 ++++++ package-lock.json | 89 ++++++++++++++++++- package.json | 2 + src/common/guards/roles.guard.ts | 2 +- src/main.ts | 78 +++++++++------- src/modules/authentication/auth.module.ts | 21 +++-- src/modules/authentication/authrequests.http | 1 + .../controllers/auth.controller.ts | 17 ++++ .../controllers/authentik-controller.ts | 19 ---- .../guards/authentik-auth.guard.ts | 12 +++ .../authentication/session.serializer.ts | 18 ++++ .../strategies/authentik.strategy.ts | 37 ++++---- 12 files changed, 246 insertions(+), 78 deletions(-) create mode 100644 src/modules/authentication/authrequests.http create mode 100644 src/modules/authentication/controllers/auth.controller.ts delete mode 100644 src/modules/authentication/controllers/authentik-controller.ts create mode 100644 src/modules/authentication/guards/authentik-auth.guard.ts create mode 100644 src/modules/authentication/session.serializer.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index b094ca8..7bb778c 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1386,6 +1386,34 @@ "Timesheets" ] } + }, + "/auth/login": { + "get": { + "operationId": "AuthController_login", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } + }, + "/auth/callback": { + "get": { + "operationId": "AuthController_loginCallback", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Auth" + ] + } } }, "info": { diff --git a/package-lock.json b/package-lock.json index 01ac1f2..3510c72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@prisma/client": "^6.11.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "express-session": "^1.18.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-openidconnect": "^0.1.2", @@ -34,6 +35,7 @@ "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", @@ -3268,6 +3270,16 @@ "@types/send": "*" } }, + "node_modules/@types/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -6243,6 +6255,46 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-session": { + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.7", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/express-session/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express-session/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/express/node_modules/content-disposition": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", @@ -6615,10 +6667,11 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -8759,6 +8812,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -9386,6 +9448,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -10876,6 +10947,18 @@ "node": ">=8" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uint8array-extras": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", diff --git a/package.json b/package.json index 4cc3728..838060b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@prisma/client": "^6.11.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "express-session": "^1.18.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "passport-openidconnect": "^0.1.2", @@ -45,6 +46,7 @@ "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", "@types/express": "^5.0.0", + "@types/express-session": "^1.18.2", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "@types/passport-jwt": "^4.0.1", diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts index 2d69de5..eba4e99 100644 --- a/src/common/guards/roles.guard.ts +++ b/src/common/guards/roles.guard.ts @@ -43,7 +43,7 @@ export class RolesGuard implements CanActivate { ); //for "deny-by-default" when role is wrong or unavailable if (!requiredRoles || requiredRoles.length === 0) { - return false; + return true; } const request = ctx.switchToHttp().getRequest(); const user = request.user; diff --git a/src/main.ts b/src/main.ts index 929668a..4c9552d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,13 @@ import 'reflect-metadata'; import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; import { ValidationPipe } from '@nestjs/common'; -import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; +// import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { OwnershipGuard } from './common/guards/ownership.guard'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { writeFileSync } from 'fs'; +import * as session from 'express-session'; +import * as passport from 'passport'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -16,41 +18,55 @@ async function bootstrap() { app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true})); app.useGlobalGuards( - new JwtAuthGuard(reflector), //Authentification JWT + // new JwtAuthGuard(reflector), //Authentification JWT new RolesGuard(reflector), //deny-by-default and Role-based Access Control new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet ); - //swagger config - const config = new DocumentBuilder() - .setTitle('Targo_Backend') - .setDescription('Documentation de l`API REST pour Targo (NestJS + Prisma)') - .setVersion('1.0') - .addBearerAuth({ - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - name: 'Authorization', - description: 'Invalid JWT token', - in: 'header', - }, 'access-token') - .addTag('Users') - .addTag('Employees') - .addTag('Customers') - .addTag('Timesheets') - .addTag('Shifts') - .addTag('Leave Requests') - .addTag('Shift Codes') - .addTag('OAuth Access Tokens') - .addTag('Authorization') - .build(); - - //document builder for swagger docs - const documentFactory = () => SwaggerModule.createDocument(app, config); - const document = documentFactory() - SwaggerModule.setup('api/docs', app, document); + // Authentication and session + app.use(session({ + secret: 'This is a super secret dev secret that you cant share with anyone', + resave: false, + saveUninitialized: false, + rolling: true, + cookie: { + maxAge: 30 * 60 * 1000, + httpOnly: false, + } + })) + app.use(passport.initialize()); + app.use(passport.session()); - writeFileSync('./docs/swagger/swagger-spec.json', JSON.stringify(document, null, 2)); + //swagger config + const config = new DocumentBuilder() + .setTitle('Targo_Backend') + .setDescription('Documentation de l`API REST pour Targo (NestJS + Prisma)') + .setVersion('1.0') + .addBearerAuth({ + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + name: 'Authorization', + description: 'Invalid JWT token', + in: 'header', + }, 'access-token') + .addTag('Users') + .addTag('Employees') + .addTag('Customers') + .addTag('Timesheets') + .addTag('Shifts') + .addTag('Leave Requests') + .addTag('Shift Codes') + .addTag('OAuth Access Tokens') + .addTag('Authorization') + .build(); + + //document builder for swagger docs + const documentFactory = () => SwaggerModule.createDocument(app, config); + const document = documentFactory() + SwaggerModule.setup('api/docs', app, document); + + writeFileSync('./docs/swagger/swagger-spec.json', JSON.stringify(document, null, 2)); await app.listen(process.env.PORT ?? 3000); } diff --git a/src/modules/authentication/auth.module.ts b/src/modules/authentication/auth.module.ts index db24560..9b9b52e 100644 --- a/src/modules/authentication/auth.module.ts +++ b/src/modules/authentication/auth.module.ts @@ -3,19 +3,22 @@ import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { AuthentikAuthService } from './services/authentik-auth.service'; import { UsersModule } from '../users-management/users.module'; +import { AuthController } from './controllers/auth.controller'; +import { AuthentikStrategy } from './strategies/authentik.strategy'; +import { SessionSerializer } from './session.serializer'; @Module({ - imports: [ - PassportModule.register({ defaultStrategy: 'jwt' }), - JwtModule.register({ - secret: process.env.JWT_SECRET || 'CHANGE_ME', - signOptions: { expiresIn: '1h' }, - }), - UsersModule, + imports: [ PassportModule.register({ + session: true, + defaultStrategy: 'openidconnect' + }), UsersModule, ], + providers: [ + AuthentikAuthService, + AuthentikStrategy, + SessionSerializer, ], - providers: [ AuthentikAuthService, ], exports: [ AuthentikAuthService ], - + controllers: [AuthController], }) export class AuthenticationModule {} diff --git a/src/modules/authentication/authrequests.http b/src/modules/authentication/authrequests.http new file mode 100644 index 0000000..a91dfcf --- /dev/null +++ b/src/modules/authentication/authrequests.http @@ -0,0 +1 @@ +POST http://localhost:3000/auth/login \ No newline at end of file diff --git a/src/modules/authentication/controllers/auth.controller.ts b/src/modules/authentication/controllers/auth.controller.ts new file mode 100644 index 0000000..687aea9 --- /dev/null +++ b/src/modules/authentication/controllers/auth.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common'; +import { OIDCLoginGuard } from '../guards/authentik-auth.guard'; +import { Request, Response } from 'express'; + +@Controller('auth') +export class AuthController { + + @UseGuards(OIDCLoginGuard) + @Get('/login') + login() {} + + @Get('/callback') + @UseGuards(OIDCLoginGuard) + loginCallback(@Req() req: Request, @Res() res: Response) { + res.redirect('/'); + } +} diff --git a/src/modules/authentication/controllers/authentik-controller.ts b/src/modules/authentication/controllers/authentik-controller.ts deleted file mode 100644 index 13eb537..0000000 --- a/src/modules/authentication/controllers/authentik-controller.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; -import { Request } from 'express'; - -@Controller('auth') -export class AuthController { - @Post('/login') - login() { - // Passport handles redirection to Authentik - } - - @Get('callback') - // @UseGuards(AuthGuard('openidconnect')) - callback(@Req() req: Request) { - console.log('✅ Auth complete, here is req.user:'); - console.dir(req.user, { depth: null }); - - return req.user; - } -} diff --git a/src/modules/authentication/guards/authentik-auth.guard.ts b/src/modules/authentication/guards/authentik-auth.guard.ts new file mode 100644 index 0000000..df9208c --- /dev/null +++ b/src/modules/authentication/guards/authentik-auth.guard.ts @@ -0,0 +1,12 @@ +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class OIDCLoginGuard extends AuthGuard('openidconnect') { + async canActivate(context: ExecutionContext) { + const result = (await super.canActivate(context)) as boolean; + const request = context.switchToHttp().getRequest(); + await super.logIn(request); + return result; + } +} \ No newline at end of file diff --git a/src/modules/authentication/session.serializer.ts b/src/modules/authentication/session.serializer.ts new file mode 100644 index 0000000..a1311cc --- /dev/null +++ b/src/modules/authentication/session.serializer.ts @@ -0,0 +1,18 @@ +import { PassportSerializer } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; + +@Injectable() +export class SessionSerializer extends PassportSerializer { + serializeUser(user: any, done: (err: any, user: any) => void): any { + if (!user){ + done(new UnauthorizedException('Serialize user error'), user); + } + done(null, user); + } + deserializeUser(payload: any, done: (err: any, payload: string) => void): any { + if (!payload){ + done(new UnauthorizedException('Deserialize user error'), payload); + } + done(null, payload); + } +} \ No newline at end of file diff --git a/src/modules/authentication/strategies/authentik.strategy.ts b/src/modules/authentication/strategies/authentik.strategy.ts index a6bb526..492028a 100644 --- a/src/modules/authentication/strategies/authentik.strategy.ts +++ b/src/modules/authentication/strategies/authentik.strategy.ts @@ -1,7 +1,7 @@ -import { Strategy as OIDCStrategy } from 'passport-openidconnect'; +import { Strategy as OIDCStrategy, Profile, VerifyCallback } from 'passport-openidconnect'; import { PassportStrategy } from '@nestjs/passport'; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { AuthentikAuthService } from '../services/authentik-auth.service'; export interface AuthentikPayload { @@ -23,26 +23,33 @@ export interface AuthentikPayload { } @Injectable() -export class AuthentikStrategy extends PassportStrategy(OIDCStrategy) { +export class AuthentikStrategy extends PassportStrategy(OIDCStrategy, 'openidconnect', 8) { constructor(private authentikAuthService: AuthentikAuthService) { super({ issuer: process.env.AUTHENTIK_ISSUER || "ISSUER_MISSING", clientID: process.env.AUTHENTIK_CLIENT_ID || 'CLIENT_ID_MISSING', clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'CLIENT_SECRET_MISSING', callbackURL: process.env.AUTHENTIK_CALLBACK_URL || 'CALLBACK_URL_MISSING', - authorizationURL: `${process.env.AUTHENTIK_ISSUER}/authorize/`, - tokenURL: `${process.env.AUTHENTIK_ISSUER}/token/`, - userInfoURL: `${process.env.AUTHENTIK_ISSUER}/userinfo/`, - scope: ['openid', 'email', 'profile'], - }); + authorizationURL: process.env.AUTHENTIK_AUTH_URL || 'AUTH_URL_MISSING', + tokenURL: process.env.AUTHENTIK_TOKEN_URL || 'TOKEN_URL_MISSING', + userInfoURL: process.env.AUTHENTIK_USERINFO_URL || 'USERINFO_URL_MISSING', + scope: ['openid', 'email', 'profile', 'offline_access'], + },); } - - async validate(payload: AuthentikPayload): Promise { - // if ( !payload.email_verified ) { throw new UnauthorizedException(); } - - const user = await this.authentikAuthService.validateUser(payload.email); - if ( !user ) { throw new UnauthorizedException(); } + + async validate( + issuer: string, + profile: Profile, + _context: any, + idToken: string, + accessToken: string, + refreshToken: string, + params: any, + cb: VerifyCallback, + ): Promise { - return user; + // saving all info from validate() into NestJS session under 'user' + /* TODO: Will need to add authorization logic with Prisma here later */ + return cb(null, { issuer, ...profile, idToken, accessToken, refreshToken, ...params }); } }