refactor(auth): add functionality to complete auth cycle, utilizer user from request.

This commit is contained in:
Nicolas Drolet 2025-10-21 10:50:11 -04:00
parent d1c41ea1bd
commit 1dbc0bf6c2
8 changed files with 159 additions and 69 deletions

View File

@ -221,6 +221,46 @@
]
}
},
"/employees/profile/{email}": {
"get": {
"operationId": "EmployeesController_findOneProfile",
"parameters": [
{
"name": "email",
"required": true,
"in": "path",
"description": "Identifier of the employee",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Employee profile found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EmployeeProfileItemDto"
}
}
}
},
"400": {
"description": "Employee profile not found"
}
},
"security": [
{
"access-token": []
}
],
"summary": "Find employee profile",
"tags": [
"Employees"
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetsController_getPeriodByQuery",
@ -680,6 +720,20 @@
]
}
},
"/auth/me": {
"get": {
"operationId": "AuthController_getProfile",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Auth"
]
}
},
"/oauth-sessions": {
"post": {
"operationId": "OauthSessionsController_create",
@ -1463,6 +1517,10 @@
"first_work_day"
]
},
"EmployeeProfileItemDto": {
"type": "object",
"properties": {}
},
"CreateWeekShiftsDto": {
"type": "object",
"properties": {}

View File

@ -1,17 +1,26 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Controller, Get, Req, Res, UnauthorizedException, UseGuards } from '@nestjs/common';
import { OIDCLoginGuard } from '../guards/authentik-auth.guard';
import { Request, Response } from 'express';
@Controller('auth')
export class AuthController {
@UseGuards(OIDCLoginGuard)
@Get('/v1/login')
login() {}
@Get('/callback')
@UseGuards(OIDCLoginGuard)
loginCallback(@Req() req: Request, @Res() res: Response) {
res.redirect('http://localhost:9000/#/login-success');
}
@UseGuards(OIDCLoginGuard)
@Get('/v1/login')
login() { }
@Get('/callback')
@UseGuards(OIDCLoginGuard)
loginCallback(@Req() req: Request, @Res() res: Response) {
res.redirect('http://localhost:9000/#/login-success');
}
@Get('/me')
getProfile(@Req() req: Request) {
if (!req.user) {
throw new UnauthorizedException('Not logged in');
}
return req.user;
}
}

View File

@ -3,53 +3,63 @@ import { Strategy as OIDCStrategy, Profile, VerifyCallback } from 'passport-open
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { AuthentikAuthService } from '../services/authentik-auth.service';
import { ValidationError } from 'class-validator';
export interface AuthentikPayload {
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string; // Audience (client ID)
exp: number; // Expiration time (Unix)
iat: number; // Issued at time (Unix)
auth_time: number; // Time of authentication (Unix)
acr?: string; // Auth Context Class Reference
amr?: string[]; // Auth Method References (e.g., ['pwd'])
email: string;
email_verified: boolean;
name?: string;
given_name?: string;
preferred_username?: string;
nickname?: string;
groups?: string[];
iss: string; // Issuer
sub: string; // Subject (user ID)
aud: string; // Audience (client ID)
exp: number; // Expiration time (Unix)
iat: number; // Issued at time (Unix)
auth_time: number; // Time of authentication (Unix)
acr?: string; // Auth Context Class Reference
amr?: string[]; // Auth Method References (e.g., ['pwd'])
email: string;
email_verified: boolean;
name?: string;
given_name?: string;
preferred_username?: string;
nickname?: string;
groups?: string[];
}
@Injectable()
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_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(
issuer: string,
profile: Profile,
_context: any,
idToken: string,
accessToken: string,
refreshToken: string,
params: any,
cb: VerifyCallback,
): Promise<any> {
// 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 });
}
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_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(
_issuer: string,
profile: Profile,
_context: any,
_idToken: string,
_accessToken: string,
_refreshToken: string,
_params: any,
cb: VerifyCallback,
): Promise<any> {
try {
const email = profile.emails?.[0]?.value;
console.log('email: ', email);
if (!email) return cb(new Error('Missing email in OIDC profile'), false);
const user = await this.authentikAuthService.validateUser(email);
console.log('user: ', user);
if (!user) return cb(new Error('User not found'), false);
return cb(null, user);
} catch (err) {
return cb(err, false);
}
}
}

View File

@ -6,6 +6,7 @@ import { RolesAllowed } from '../../../common/decorators/roles.decorators';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { EmployeeListItemDto } from '../dtos/list-employee.dto';
import { EmployeesArchivalService } from '../services/employees-archival.service';
import { EmployeeProfileItemDto } from 'src/modules/employees/dtos/profil-employee.dto';
@ApiTags('Employees')
@ApiBearerAuth('access-token')
@ -76,14 +77,14 @@ export class EmployeesController {
// return this.employeesService.findOne(email);
// }
// @Get('profile/:email')
// @ApiOperation({summary: 'Find employee profile' })
// @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
// @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
// @ApiResponse({ status: 400, description: 'Employee profile not found' })
// findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
// return this.employeesService.findOneProfile(email);
// }
@Get('profile/:email')
@ApiOperation({summary: 'Find employee profile' })
@ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' })
@ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto })
@ApiResponse({ status: 400, description: 'Employee profile not found' })
findOneProfile(@Param('email') email: string): Promise<EmployeeProfileItemDto> {
return this.employeesService.findOneProfile(email);
}
// @Delete(':email')
// //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR )

View File

@ -30,6 +30,7 @@ export class ShiftsHelpersService {
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
console.log('start of week: ', start_of_week);
return tx.timesheets.findUnique({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
select: { id: true },

View File

@ -164,7 +164,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
email: string,
date_iso: string,
dto: UpsertShiftDto,
): Promise<{ day: DayShiftResponse[]; }>{
){
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso); //converts to Date format
const employee_id = await this.emailResolver.findIdByEmail(email);
@ -174,6 +174,11 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
console.log('timesheet_id: ', timesheet.id );
console.log('date: ', date_only);
console.log('bank code id: ', bank_code_id.id);
console.log('normalized old shift: ', norm_shift);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id: timesheet.id,
date_only,
@ -184,9 +189,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
await tx.shifts.delete({ where: { id: existing.id } });
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { day: await this.helpersService.mapDay(fresh_shift)};
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
});
}
}

View File

@ -38,10 +38,10 @@ export function normalizeShiftPayload(payload: {
const asLocalDateOn = (input: string): Date => {
// HH:mm ?
const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
if (hm) return new Date(Date.UTC(1970, 0, 1, Number(hm[1]), Number(hm[2])));
const iso = new Date(input);
if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
return new Date(Date.UTC(1970, 0, 1, iso.getHours(), iso.getMinutes(), iso.getSeconds()));
};
const start_time = asLocalDateOn(payload.start_time);

View File

@ -18,12 +18,20 @@ export abstract class AbstractUserService {
return user;
}
async findOneByEmail( email: string ): Promise<Users> {
async findOneByEmail( email: string ): Promise<Partial<Users>> {
const user = await this.prisma.users.findUnique({ where: { email } });
if (!user) {
throw new NotFoundException(`No user with email #${email} exists`);
}
return user;
const clean_user = {
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
role: user.role,
}
return clean_user;
}
async remove(id: string): Promise<Users> {