feat(Oauth2): implement full Oauth2 authentication handshake with Authentik IdP. Authorization (authentik-auth.service) is disconnected for now.

This commit is contained in:
Nicolas Drolet 2025-07-24 16:22:26 -04:00
parent 2feac880e3
commit 75910e377d
12 changed files with 246 additions and 78 deletions

View File

@ -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": {

89
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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<RequestWithUser>();
const user = request.user;

View File

@ -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);
}

View File

@ -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 {}

View File

@ -0,0 +1 @@
POST http://localhost:3000/auth/login

View File

@ -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('/');
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<any> {
// 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<any> {
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 });
}
}