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

89
package-lock.json generated
View File

@ -19,6 +19,7 @@
"@prisma/client": "^6.11.1", "@prisma/client": "^6.11.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"express-session": "^1.18.2",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2", "passport-openidconnect": "^0.1.2",
@ -34,6 +35,7 @@
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
@ -3268,6 +3270,16 @@
"@types/send": "*" "@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": { "node_modules/@types/graceful-fs": {
"version": "4.1.9", "version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@ -6243,6 +6255,46 @@
"url": "https://opencollective.com/express" "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": { "node_modules/express/node_modules/content-disposition": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
@ -6615,10 +6667,11 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
@ -8759,6 +8812,15 @@
"node": ">= 0.8" "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": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -9386,6 +9448,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/randombytes": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -10876,6 +10947,18 @@
"node": ">=8" "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": { "node_modules/uint8array-extras": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz",

View File

@ -30,6 +30,7 @@
"@prisma/client": "^6.11.1", "@prisma/client": "^6.11.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"express-session": "^1.18.2",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-openidconnect": "^0.1.2", "passport-openidconnect": "^0.1.2",
@ -45,6 +46,7 @@
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/passport-jwt": "^4.0.1", "@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 //for "deny-by-default" when role is wrong or unavailable
if (!requiredRoles || requiredRoles.length === 0) { if (!requiredRoles || requiredRoles.length === 0) {
return false; return true;
} }
const request = ctx.switchToHttp().getRequest<RequestWithUser>(); const request = ctx.switchToHttp().getRequest<RequestWithUser>();
const user = request.user; const user = request.user;

View File

@ -2,11 +2,13 @@ import 'reflect-metadata';
import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { ModuleRef, NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; 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 { RolesGuard } from './common/guards/roles.guard';
import { OwnershipGuard } from './common/guards/ownership.guard'; import { OwnershipGuard } from './common/guards/ownership.guard';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import * as session from 'express-session';
import * as passport from 'passport';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
@ -16,41 +18,55 @@ async function bootstrap() {
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ whitelist: true, transform: true})); new ValidationPipe({ whitelist: true, transform: true}));
app.useGlobalGuards( app.useGlobalGuards(
new JwtAuthGuard(reflector), //Authentification JWT // new JwtAuthGuard(reflector), //Authentification JWT
new RolesGuard(reflector), //deny-by-default and Role-based Access Control new RolesGuard(reflector), //deny-by-default and Role-based Access Control
new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet
); );
//swagger config // Authentication and session
const config = new DocumentBuilder() app.use(session({
.setTitle('Targo_Backend') secret: 'This is a super secret dev secret that you cant share with anyone',
.setDescription('Documentation de l`API REST pour Targo (NestJS + Prisma)') resave: false,
.setVersion('1.0') saveUninitialized: false,
.addBearerAuth({ rolling: true,
type: 'http', cookie: {
scheme: 'bearer', maxAge: 30 * 60 * 1000,
bearerFormat: 'JWT', httpOnly: false,
name: 'Authorization', }
description: 'Invalid JWT token', }))
in: 'header', app.use(passport.initialize());
}, 'access-token') app.use(passport.session());
.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)); //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); await app.listen(process.env.PORT ?? 3000);
} }

View File

@ -3,19 +3,22 @@ import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { AuthentikAuthService } from './services/authentik-auth.service'; import { AuthentikAuthService } from './services/authentik-auth.service';
import { UsersModule } from '../users-management/users.module'; 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({ @Module({
imports: [ imports: [ PassportModule.register({
PassportModule.register({ defaultStrategy: 'jwt' }), session: true,
JwtModule.register({ defaultStrategy: 'openidconnect'
secret: process.env.JWT_SECRET || 'CHANGE_ME', }), UsersModule, ],
signOptions: { expiresIn: '1h' }, providers: [
}), AuthentikAuthService,
UsersModule, AuthentikStrategy,
SessionSerializer,
], ],
providers: [ AuthentikAuthService, ],
exports: [ AuthentikAuthService ], exports: [ AuthentikAuthService ],
controllers: [AuthController],
}) })
export class AuthenticationModule {} 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 { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthentikAuthService } from '../services/authentik-auth.service'; import { AuthentikAuthService } from '../services/authentik-auth.service';
export interface AuthentikPayload { export interface AuthentikPayload {
@ -23,26 +23,33 @@ export interface AuthentikPayload {
} }
@Injectable() @Injectable()
export class AuthentikStrategy extends PassportStrategy(OIDCStrategy) { export class AuthentikStrategy extends PassportStrategy(OIDCStrategy, 'openidconnect', 8) {
constructor(private authentikAuthService: AuthentikAuthService) { constructor(private authentikAuthService: AuthentikAuthService) {
super({ super({
issuer: process.env.AUTHENTIK_ISSUER || "ISSUER_MISSING", issuer: process.env.AUTHENTIK_ISSUER || "ISSUER_MISSING",
clientID: process.env.AUTHENTIK_CLIENT_ID || 'CLIENT_ID_MISSING', clientID: process.env.AUTHENTIK_CLIENT_ID || 'CLIENT_ID_MISSING',
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'CLIENT_SECRET_MISSING', clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'CLIENT_SECRET_MISSING',
callbackURL: process.env.AUTHENTIK_CALLBACK_URL || 'CALLBACK_URL_MISSING', callbackURL: process.env.AUTHENTIK_CALLBACK_URL || 'CALLBACK_URL_MISSING',
authorizationURL: `${process.env.AUTHENTIK_ISSUER}/authorize/`, authorizationURL: process.env.AUTHENTIK_AUTH_URL || 'AUTH_URL_MISSING',
tokenURL: `${process.env.AUTHENTIK_ISSUER}/token/`, tokenURL: process.env.AUTHENTIK_TOKEN_URL || 'TOKEN_URL_MISSING',
userInfoURL: `${process.env.AUTHENTIK_ISSUER}/userinfo/`, userInfoURL: process.env.AUTHENTIK_USERINFO_URL || 'USERINFO_URL_MISSING',
scope: ['openid', 'email', 'profile'], scope: ['openid', 'email', 'profile', 'offline_access'],
}); },);
} }
async validate(payload: AuthentikPayload): Promise<any> { async validate(
// if ( !payload.email_verified ) { throw new UnauthorizedException(); } issuer: string,
profile: Profile,
const user = await this.authentikAuthService.validateUser(payload.email); _context: any,
if ( !user ) { throw new UnauthorizedException(); } 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 });
} }
} }