diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 75f63f11..03b951ea 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,6 +1,5 @@ import { Controller, Post, Body, Get, Req, Res, UseGuards, UnauthorizedException } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { User } from '../types/User'; import { Response } from 'express'; import { VerifyUserGuard } from "../guards/auth.guard"; import { LoginBody, RegisterBody, SetPasswordBody, UpdateProfileBody, ChangePasswordBody } from './types/auth.types'; @@ -146,13 +145,7 @@ export class AuthController { async login( @Res({ passthrough: true }) response: Response, @Body() body:LoginBody - ): Promise<{ - user: User; - session?: string; - challenge?: string; - requiredAttributes?: string[]; - position?: string; - }> { + ): Promise<{ message: string }> { const result = await this.authService.login(body.email, body.password); // Set cookie with access token @@ -190,10 +183,7 @@ export class AuthController { } - delete result.idToken; - delete result.access_token; - delete result.refreshToken; - return result + return { message: 'User logged in successfully' }; } /** @@ -215,7 +205,7 @@ export class AuthController { async refresh( @Req() req: any, @Res({ passthrough: true}) response: Response, - ): Promise<{ message: string}> { + ): Promise<{ message: string }> { const refreshToken = req.cookies?.refresh_token; @@ -236,7 +226,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, @@ -254,6 +249,14 @@ export class AuthController { path: '/', }); + 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' }; } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 8f4ede69..c13507e0 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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; @@ -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; diff --git a/backend/src/guards/auth.guard.ts b/backend/src/guards/auth.guard.ts index 67ade10f..ce2160b6 100644 --- a/backend/src/guards/auth.guard.ts +++ b/backend/src/guards/auth.guard.ts @@ -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"; @@ -27,24 +33,23 @@ export class VerifyUserGuard implements CanActivate { } async canActivate(context: ExecutionContext): Promise { - 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; @@ -73,51 +78,56 @@ export class VerifyAdminRoleGuard implements CanActivate { } async canActivate(context: ExecutionContext): Promise { - 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"); } } } @@ -145,33 +155,33 @@ export class VerifyAdminOrEmployeeRoleGuard implements CanActivate { } async canActivate(context: ExecutionContext): Promise { + 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"); } } } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 06878c34..19b88e53 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,7 +1,29 @@ // API INDEX - const BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, ''); +type ApiInit = RequestInit & { __retry?: boolean }; +let refreshInFlight: Promise | null = null; + +async function refreshTokens(): Promise { + 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 = {} @@ -9,8 +31,24 @@ export async function api( 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; }