diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 75f63f11..9d1fbd6e 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -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; @@ -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, @@ -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, + }; } /** 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; }