feat(Oauth2): implement full Oauth2 authentication handshake with Authentik IdP. Authorization (authentik-auth.service) is disconnected for now.
This commit is contained in:
parent
2feac880e3
commit
75910e377d
|
|
@ -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
89
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
78
src/main.ts
78
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
1
src/modules/authentication/authrequests.http
Normal file
1
src/modules/authentication/authrequests.http
Normal file
|
|
@ -0,0 +1 @@
|
|||
POST http://localhost:3000/auth/login
|
||||
17
src/modules/authentication/controllers/auth.controller.ts
Normal file
17
src/modules/authentication/controllers/auth.controller.ts
Normal 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('/');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
12
src/modules/authentication/guards/authentik-auth.guard.ts
Normal file
12
src/modules/authentication/guards/authentik-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/modules/authentication/session.serializer.ts
Normal file
18
src/modules/authentication/session.serializer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user