Skip to content

Commit e00da50

Browse files
authored
Merge pull request #364 from Code-4-Community/343-dev---set-up-authrefresh-route-and-frontend-refresh-token-logic
refresh token
2 parents f68ba1e + 04d6277 commit e00da50

4 files changed

Lines changed: 123 additions & 68 deletions

File tree

backend/src/auth/auth.controller.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Controller, Post, Body, Get, Req, Res, UseGuards, UnauthorizedException } from '@nestjs/common';
22
import { AuthService } from './auth.service';
3-
import { User } from '../types/User';
43
import { Response } from 'express';
54
import { VerifyUserGuard } from "../guards/auth.guard";
65
import { LoginBody, RegisterBody, SetPasswordBody, UpdateProfileBody, ChangePasswordBody } from './types/auth.types';
@@ -146,13 +145,7 @@ export class AuthController {
146145
async login(
147146
@Res({ passthrough: true }) response: Response,
148147
@Body() body:LoginBody
149-
): Promise<{
150-
user: User;
151-
session?: string;
152-
challenge?: string;
153-
requiredAttributes?: string[];
154-
position?: string;
155-
}> {
148+
): Promise<{ message: string }> {
156149
const result = await this.authService.login(body.email, body.password);
157150

158151
// Set cookie with access token
@@ -190,10 +183,7 @@ export class AuthController {
190183
}
191184

192185

193-
delete result.idToken;
194-
delete result.access_token;
195-
delete result.refreshToken;
196-
return result
186+
return { message: 'User logged in successfully' };
197187
}
198188

199189
/**
@@ -215,7 +205,7 @@ export class AuthController {
215205
async refresh(
216206
@Req() req: any,
217207
@Res({ passthrough: true}) response: Response,
218-
): Promise<{ message: string}> {
208+
): Promise<{ message: string }> {
219209

220210
const refreshToken = req.cookies?.refresh_token;
221211

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

239-
const { accessToken, idToken: newIdToken } = await this.authService.refreshTokens(refreshToken, cognitoUsername);
229+
const { accessToken, idToken: newIdToken, refreshToken: newRefreshToken } =
230+
await this.authService.refreshTokens(refreshToken, cognitoUsername);
231+
232+
// Cognito may or may not rotate refresh tokens depending on configuration.
233+
// To keep frontend contract stable, we always return the refresh token we're using.
234+
const effectiveRefreshToken = newRefreshToken ?? refreshToken;
240235

241236
response.cookie('access_token', accessToken, {
242237
httpOnly: true,
@@ -254,6 +249,14 @@ export class AuthController {
254249
path: '/',
255250
});
256251

252+
response.cookie('refresh_token', effectiveRefreshToken, {
253+
httpOnly: true,
254+
secure: process.env.NODE_ENV === 'production',
255+
sameSite: 'strict',
256+
maxAge: 30 * 24 * 60 * 60 * 1000, // match Cognito refresh token expiry (approx)
257+
path: '/auth/refresh',
258+
});
259+
257260
return { message: 'Tokens refreshed successfully' };
258261
}
259262

backend/src/auth/auth.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,6 +962,7 @@ async updateProfile(
962962
async refreshTokens(refreshToken: string, cognitoUsername: string): Promise<{
963963
accessToken: string;
964964
idToken: string;
965+
refreshToken?: string;
965966
}> {
966967
const clientId = process.env.COGNITO_CLIENT_ID;
967968
const clientSecret = process.env.COGNITO_CLIENT_SECRET;
@@ -1009,9 +1010,12 @@ async updateProfile(
10091010

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

1013+
const newRefreshToken = response.AuthenticationResult?.RefreshToken;
1014+
10121015
return {
10131016
accessToken: response.AuthenticationResult.AccessToken,
10141017
idToken: response.AuthenticationResult.IdToken,
1018+
refreshToken: newRefreshToken,
10151019
};
10161020
} catch (error: unknown) {
10171021
const cognitoError = error as AwsCognitoError;

backend/src/guards/auth.guard.ts

Lines changed: 60 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { Injectable, CanActivate, ExecutionContext, Logger } from "@nestjs/common";
2-
import { Observable } from "rxjs";
1+
import {
2+
Injectable,
3+
CanActivate,
4+
ExecutionContext,
5+
Logger,
6+
UnauthorizedException,
7+
ForbiddenException,
8+
} from "@nestjs/common";
39
import { CognitoJwtVerifier } from "aws-jwt-verify";
410

511

@@ -27,24 +33,23 @@ export class VerifyUserGuard implements CanActivate {
2733
}
2834

2935
async canActivate(context: ExecutionContext): Promise<boolean> {
30-
try {
31-
const request = context.switchToHttp().getRequest();
32-
const accessToken = request.cookies["access_token"];
33-
if (!accessToken) {
34-
this.logger.error("No access token found in cookies");
35-
return false;
36-
}
37-
const result = await this.verifier.verify(accessToken);
36+
const request = context.switchToHttp().getRequest();
37+
const accessToken = request.cookies["access_token"];
38+
if (!accessToken) {
39+
this.logger.error("No access token found in cookies");
40+
throw new UnauthorizedException("Missing access token");
41+
}
3842

43+
try {
44+
await this.verifier.verify(accessToken);
3945
return true;
4046
} catch (error) {
41-
console.error("Token verification failed:", error); // Debug log
42-
return false;
47+
this.logger.error("Token verification failed:", error);
48+
throw new UnauthorizedException("Invalid or expired access token");
4349
}
4450
}
4551
}
4652

47-
@Injectable()
4853
@Injectable()
4954
export class VerifyAdminRoleGuard implements CanActivate {
5055
private verifier: any;
@@ -73,51 +78,56 @@ export class VerifyAdminRoleGuard implements CanActivate {
7378
}
7479

7580
async canActivate(context: ExecutionContext): Promise<boolean> {
76-
try {
77-
const request = context.switchToHttp().getRequest();
78-
const accessToken = request.cookies["access_token"];
79-
const idToken = request.cookies["id_token"];
81+
const request = context.switchToHttp().getRequest();
82+
const accessToken = request.cookies["access_token"];
83+
const idToken = request.cookies["id_token"];
8084

81-
if (!accessToken) {
82-
this.logger.error("No access token found in cookies");
83-
return false;
84-
}
85+
if (!accessToken) {
86+
this.logger.error("No access token found in cookies");
87+
throw new UnauthorizedException("Missing access token");
88+
}
8589

86-
if (!idToken) {
87-
this.logger.error("No ID token found in cookies");
88-
return false;
89-
}
90+
if (!idToken) {
91+
this.logger.error("No ID token found in cookies");
92+
throw new UnauthorizedException("Missing id token");
93+
}
9094

95+
try {
9196
const [result, idResult] = await Promise.all([
9297
this.verifier.verify(accessToken),
9398
this.idVerifier.verify(idToken),
9499
]);
95100

96-
const groups = result['cognito:groups'] || [];
97-
const email = idResult['email'];
101+
const groups = result["cognito:groups"] || [];
102+
const email = idResult["email"];
98103

99104
if (!email) {
100105
this.logger.error("No email found in ID token claims");
101-
return false;
106+
throw new UnauthorizedException("Invalid id token");
102107
}
103108

104109
// Attach user info to request for use in controllers
105110
request.user = {
106111
email,
107-
position: groups.includes('Admin') ? 'Admin' : (groups.includes('Employee') ? 'Employee' : 'Inactive')
112+
position: groups.includes("Admin")
113+
? "Admin"
114+
: groups.includes("Employee")
115+
? "Employee"
116+
: "Inactive",
108117
};
109118

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

112-
if (!groups.includes('Admin')) {
121+
if (!groups.includes("Admin")) {
113122
this.logger.warn("Access denied: User is not an Admin");
114-
return false;
123+
throw new ForbiddenException("Admin access required");
115124
}
116125

117126
return true;
118127
} catch (error) {
128+
if (error instanceof ForbiddenException) throw error;
119129
this.logger.error("Token verification failed:", error);
120-
return false;
130+
throw new UnauthorizedException("Invalid or expired token");
121131
}
122132
}
123133
}
@@ -145,33 +155,33 @@ export class VerifyAdminOrEmployeeRoleGuard implements CanActivate {
145155
}
146156

147157
async canActivate(context: ExecutionContext): Promise<boolean> {
158+
const request = context.switchToHttp().getRequest();
159+
const accessToken = request.cookies["access_token"];
160+
161+
if (!accessToken) {
162+
this.logger.error("No access token found in cookies");
163+
throw new UnauthorizedException("Missing access token");
164+
}
165+
148166
try {
149-
const request = context.switchToHttp().getRequest();
150-
const accessToken = request.cookies["access_token"];
151-
152-
if (!accessToken) {
153-
this.logger.error("No access token found in cookies");
154-
return false;
155-
}
156-
157167
const result = await this.verifier.verify(accessToken);
158-
const groups = result['cognito:groups'] || [];
159-
160-
this.logger.log(`User groups from token: ${groups.join(', ')}`);
161-
168+
const groups = result["cognito:groups"] || [];
169+
170+
this.logger.log(`User groups from token: ${groups.join(", ")}`);
171+
162172
// Check if user is either Admin or Employee
163-
const isAuthorized = groups.includes('Admin') || groups.includes('Employee');
164-
173+
const isAuthorized = groups.includes("Admin") || groups.includes("Employee");
174+
165175
if (!isAuthorized) {
166176
this.logger.warn("Access denied: User is not an Admin or Employee");
167-
return false;
177+
throw new ForbiddenException("Insufficient role permissions");
168178
}
169-
179+
170180
return true;
171-
172181
} catch (error) {
182+
if (error instanceof ForbiddenException) throw error;
173183
this.logger.error("Token verification failed:", error);
174-
return false;
184+
throw new UnauthorizedException("Invalid or expired access token");
175185
}
176186
}
177187
}

frontend/src/api.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,54 @@
11
// API INDEX
2-
32
const BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '');
43

4+
type ApiInit = RequestInit & { __retry?: boolean };
5+
let refreshInFlight: Promise<boolean> | null = null;
6+
7+
async function refreshTokens(): Promise<boolean> {
8+
if (refreshInFlight) return refreshInFlight;
9+
10+
refreshInFlight = (async () => {
11+
try {
12+
const refreshResp = await fetch(`${BASE}/auth/refresh`, {
13+
method: 'POST',
14+
credentials: 'include',
15+
});
16+
return refreshResp.ok;
17+
} catch {
18+
return false;
19+
} finally {
20+
refreshInFlight = null;
21+
}
22+
})();
23+
24+
return refreshInFlight;
25+
}
26+
527
export async function api(
628
path: string,
729
init: RequestInit = {}
830
): Promise<Response> {
931
const cleanPath = path.startsWith('/') ? path : `/${path}`;
1032
const url = `${BASE}${cleanPath}`;
1133

12-
return fetch(url, {
13-
credentials: 'include', // ← send & receive the jwt cookie
14-
...init,
34+
const typedInit = init as ApiInit;
35+
const { __retry, ...fetchInit } = typedInit;
36+
37+
const resp = await fetch(url, {
38+
credentials: 'include', // send & receive the jwt cookie
39+
...fetchInit,
1540
});
41+
42+
// If access token is expired/invalid, try refreshing once and replay the request.
43+
if (!__retry && resp.status === 401 && cleanPath !== '/auth/refresh') {
44+
const refreshed = await refreshTokens();
45+
if (refreshed) {
46+
return fetch(url, {
47+
credentials: 'include',
48+
...fetchInit,
49+
});
50+
}
51+
}
52+
53+
return resp;
1654
}

0 commit comments

Comments
 (0)