Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ export class AuthController {
async refresh(
@Req() req: any,
@Res({ passthrough: true}) response: Response,
): Promise<{ message: string}> {
): Promise<{ message: string; refreshToken: string; idToken: string }> {

const refreshToken = req.cookies?.refresh_token;

Expand All @@ -236,7 +236,12 @@ export class AuthController {
throw new UnauthorizedException('Could not extract user identity from token');
}

const { accessToken, idToken: newIdToken } = await this.authService.refreshTokens(refreshToken, cognitoUsername);
const { accessToken, idToken: newIdToken, refreshToken: newRefreshToken } =
await this.authService.refreshTokens(refreshToken, cognitoUsername);

// Cognito may or may not rotate refresh tokens depending on configuration.
// To keep frontend contract stable, we always return the refresh token we're using.
const effectiveRefreshToken = newRefreshToken ?? refreshToken;

response.cookie('access_token', accessToken, {
httpOnly: true,
Expand All @@ -254,7 +259,19 @@ export class AuthController {
path: '/',
});

return { message: 'Tokens refreshed successfully' };
response.cookie('refresh_token', effectiveRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // match Cognito refresh token expiry (approx)
path: '/auth/refresh',
});

return {
message: 'Tokens refreshed successfully',
refreshToken: effectiveRefreshToken,
idToken: newIdToken,
};
}

/**
Expand Down
4 changes: 4 additions & 0 deletions backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,7 @@ async updateProfile(
async refreshTokens(refreshToken: string, cognitoUsername: string): Promise<{
accessToken: string;
idToken: string;
refreshToken?: string;
}> {
const clientId = process.env.COGNITO_CLIENT_ID;
const clientSecret = process.env.COGNITO_CLIENT_SECRET;
Expand Down Expand Up @@ -1009,9 +1010,12 @@ async updateProfile(

this.logger.log(`Tokens refreshed successfully for user: ${cognitoUsername}`);

const newRefreshToken = response.AuthenticationResult?.RefreshToken;

return {
accessToken: response.AuthenticationResult.AccessToken,
idToken: response.AuthenticationResult.IdToken,
refreshToken: newRefreshToken,
};
} catch (error: unknown) {
const cognitoError = error as AwsCognitoError;
Expand Down
110 changes: 60 additions & 50 deletions backend/src/guards/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { Injectable, CanActivate, ExecutionContext, Logger } from "@nestjs/common";
import { Observable } from "rxjs";
import {
Injectable,
CanActivate,
ExecutionContext,
Logger,
UnauthorizedException,
ForbiddenException,
} from "@nestjs/common";
import { CognitoJwtVerifier } from "aws-jwt-verify";


Expand Down Expand Up @@ -27,24 +33,23 @@ export class VerifyUserGuard implements CanActivate {
}

async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];
if (!accessToken) {
this.logger.error("No access token found in cookies");
return false;
}
const result = await this.verifier.verify(accessToken);
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];
if (!accessToken) {
this.logger.error("No access token found in cookies");
throw new UnauthorizedException("Missing access token");
}

try {
await this.verifier.verify(accessToken);
return true;
} catch (error) {
console.error("Token verification failed:", error); // Debug log
return false;
this.logger.error("Token verification failed:", error);
throw new UnauthorizedException("Invalid or expired access token");
}
}
}

@Injectable()
@Injectable()
export class VerifyAdminRoleGuard implements CanActivate {
private verifier: any;
Expand Down Expand Up @@ -73,51 +78,56 @@ export class VerifyAdminRoleGuard implements CanActivate {
}

async canActivate(context: ExecutionContext): Promise<boolean> {
try {
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];
const idToken = request.cookies["id_token"];
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];
const idToken = request.cookies["id_token"];

if (!accessToken) {
this.logger.error("No access token found in cookies");
return false;
}
if (!accessToken) {
this.logger.error("No access token found in cookies");
throw new UnauthorizedException("Missing access token");
}

if (!idToken) {
this.logger.error("No ID token found in cookies");
return false;
}
if (!idToken) {
this.logger.error("No ID token found in cookies");
throw new UnauthorizedException("Missing id token");
}

try {
const [result, idResult] = await Promise.all([
this.verifier.verify(accessToken),
this.idVerifier.verify(idToken),
]);

const groups = result['cognito:groups'] || [];
const email = idResult['email'];
const groups = result["cognito:groups"] || [];
const email = idResult["email"];

if (!email) {
this.logger.error("No email found in ID token claims");
return false;
throw new UnauthorizedException("Invalid id token");
}

// Attach user info to request for use in controllers
request.user = {
email,
position: groups.includes('Admin') ? 'Admin' : (groups.includes('Employee') ? 'Employee' : 'Inactive')
position: groups.includes("Admin")
? "Admin"
: groups.includes("Employee")
? "Employee"
: "Inactive",
};

this.logger.log(`User groups from token: ${groups}`);

if (!groups.includes('Admin')) {
if (!groups.includes("Admin")) {
this.logger.warn("Access denied: User is not an Admin");
return false;
throw new ForbiddenException("Admin access required");
}

return true;
} catch (error) {
if (error instanceof ForbiddenException) throw error;
this.logger.error("Token verification failed:", error);
return false;
throw new UnauthorizedException("Invalid or expired token");
}
}
}
Expand Down Expand Up @@ -145,33 +155,33 @@ export class VerifyAdminOrEmployeeRoleGuard implements CanActivate {
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];

if (!accessToken) {
this.logger.error("No access token found in cookies");
throw new UnauthorizedException("Missing access token");
}

try {
const request = context.switchToHttp().getRequest();
const accessToken = request.cookies["access_token"];

if (!accessToken) {
this.logger.error("No access token found in cookies");
return false;
}

const result = await this.verifier.verify(accessToken);
const groups = result['cognito:groups'] || [];
this.logger.log(`User groups from token: ${groups.join(', ')}`);
const groups = result["cognito:groups"] || [];

this.logger.log(`User groups from token: ${groups.join(", ")}`);

// Check if user is either Admin or Employee
const isAuthorized = groups.includes('Admin') || groups.includes('Employee');
const isAuthorized = groups.includes("Admin") || groups.includes("Employee");

if (!isAuthorized) {
this.logger.warn("Access denied: User is not an Admin or Employee");
return false;
throw new ForbiddenException("Insufficient role permissions");
}

return true;

} catch (error) {
if (error instanceof ForbiddenException) throw error;
this.logger.error("Token verification failed:", error);
return false;
throw new UnauthorizedException("Invalid or expired access token");
}
}
}
46 changes: 42 additions & 4 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
// API INDEX

const BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');

type ApiInit = RequestInit & { __retry?: boolean };
let refreshInFlight: Promise<boolean> | null = null;

async function refreshTokens(): Promise<boolean> {
if (refreshInFlight) return refreshInFlight;

refreshInFlight = (async () => {
try {
const refreshResp = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
return refreshResp.ok;
} catch {
return false;
} finally {
refreshInFlight = null;
}
})();

return refreshInFlight;
}

export async function api(
path: string,
init: RequestInit = {}
): Promise<Response> {
const cleanPath = path.startsWith('/') ? path : `/${path}`;
const url = `${BASE}${cleanPath}`;

return fetch(url, {
credentials: 'include', // ← send & receive the jwt cookie
...init,
const typedInit = init as ApiInit;
const { __retry, ...fetchInit } = typedInit;

const resp = await fetch(url, {
credentials: 'include', // send & receive the jwt cookie
...fetchInit,
});

// If access token is expired/invalid, try refreshing once and replay the request.
if (!__retry && resp.status === 401 && cleanPath !== '/auth/refresh') {
const refreshed = await refreshTokens();
if (refreshed) {
return fetch(url, {
credentials: 'include',
...fetchInit,
});
}
}

return resp;
}
Loading