Skip to content

Commit b6962cc

Browse files
authored
fix(auth): validate stored JWT server-side before allowing protected navigation (#334)
1 parent 8df0d70 commit b6962cc

4 files changed

Lines changed: 364 additions & 20 deletions

File tree

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { Router } from '@angular/router';
3+
import { provideHttpClient } from '@angular/common/http';
4+
import {
5+
HttpTestingController,
6+
provideHttpClientTesting,
7+
} from '@angular/common/http/testing';
8+
import { authGuard } from './auth.guard';
9+
import { AuthService } from './services/auth.service';
10+
import { environment } from '../environments/environment';
11+
12+
describe('authGuard', () => {
13+
let httpMock: HttpTestingController;
14+
let authService: AuthService;
15+
let router: Router;
16+
17+
beforeEach(() => {
18+
TestBed.configureTestingModule({
19+
providers: [
20+
provideHttpClient(),
21+
provideHttpClientTesting(),
22+
AuthService,
23+
{
24+
provide: Router,
25+
useValue: { navigate: jasmine.createSpy('navigate') },
26+
},
27+
],
28+
});
29+
httpMock = TestBed.inject(HttpTestingController);
30+
authService = TestBed.inject(AuthService);
31+
router = TestBed.inject(Router);
32+
});
33+
34+
afterEach(() => {
35+
httpMock.verify();
36+
localStorage.clear();
37+
});
38+
39+
it('should deny access when no token exists', async () => {
40+
const result = await TestBed.runInInjectionContext(() =>
41+
authGuard({} as any, {} as any),
42+
);
43+
expect(result).toBe(false);
44+
expect(router.navigate).toHaveBeenCalledWith(['/login']);
45+
});
46+
47+
it('should deny access when local token is expired', async () => {
48+
const pastExp = Math.floor(Date.now() / 1000) - 3600;
49+
const token = createJwt({ sub: 'user1', exp: pastExp });
50+
authService.saveToken(token);
51+
52+
const result = await TestBed.runInInjectionContext(() =>
53+
authGuard({} as any, {} as any),
54+
);
55+
expect(result).toBe(false);
56+
expect(router.navigate).toHaveBeenCalledWith(['/login']);
57+
});
58+
59+
it('should allow access when local token is valid and server refresh succeeds', async () => {
60+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
61+
const oldToken = createJwt({ sub: 'user1', exp: futureExp });
62+
const newToken = createJwt({ sub: 'user1', exp: futureExp + 7200 });
63+
authService.saveToken(oldToken);
64+
65+
const guardPromise = TestBed.runInInjectionContext(() =>
66+
authGuard({} as any, {} as any),
67+
);
68+
69+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
70+
expect(req.request.method).toBe('POST');
71+
req.flush({ token: newToken });
72+
73+
const result = await guardPromise;
74+
expect(result).toBe(true);
75+
expect(router.navigate).not.toHaveBeenCalled();
76+
});
77+
78+
it('should deny access when local token is valid but server rejects refresh (401)', async () => {
79+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
80+
const token = createJwt({ sub: 'user1', exp: futureExp });
81+
authService.saveToken(token);
82+
83+
const guardPromise = TestBed.runInInjectionContext(() =>
84+
authGuard({} as any, {} as any),
85+
);
86+
87+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
88+
req.flush(null, { status: 401, statusText: 'Unauthorized' });
89+
90+
const result = await guardPromise;
91+
expect(result).toBe(false);
92+
expect(router.navigate).toHaveBeenCalledWith(['/login']);
93+
expect(localStorage.getItem('token')).toBeNull();
94+
});
95+
96+
it('should deny access on server error and redirect to login', async () => {
97+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
98+
const token = createJwt({ sub: 'user1', exp: futureExp });
99+
authService.saveToken(token);
100+
101+
const guardPromise = TestBed.runInInjectionContext(() =>
102+
authGuard({} as any, {} as any),
103+
);
104+
105+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
106+
req.error(new ErrorEvent('Network error'));
107+
108+
const result = await guardPromise;
109+
expect(result).toBe(false);
110+
expect(router.navigate).toHaveBeenCalledWith(['/login']);
111+
});
112+
});
113+
114+
function createJwt(payload: any): string {
115+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
116+
const payloadStr = btoa(JSON.stringify(payload));
117+
const signature = 'dummy_signature';
118+
return `${header}.${payloadStr}.${signature}`;
119+
}

frontend/src/app/auth.guard.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import { CanActivateFn, Router } from '@angular/router';
22
import { AuthService } from './services/auth.service';
33
import { inject } from '@angular/core';
4+
import { firstValueFrom } from 'rxjs';
45

5-
export const authGuard: CanActivateFn = (_route, _state) => {
6+
export const authGuard: CanActivateFn = async (_route, _state) => {
67
const authService = inject(AuthService);
78
const router = inject(Router);
89

9-
if (authService.isLoggedIn()) {
10-
return true;
11-
} else {
10+
if (!authService.isLoggedInLocally()) {
1211
router.navigate(['/login']);
1312
return false;
1413
}
14+
15+
const isValidOnServer = await firstValueFrom(
16+
authService.validateTokenWithServer(),
17+
);
18+
if (!isValidOnServer) {
19+
router.navigate(['/login']);
20+
return false;
21+
}
22+
23+
return true;
1524
};
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { provideHttpClient } from '@angular/common/http';
3+
import {
4+
HttpTestingController,
5+
provideHttpClientTesting,
6+
} from '@angular/common/http/testing';
7+
import { AuthService } from './auth.service';
8+
import { Router } from '@angular/router';
9+
import { environment } from '../../environments/environment';
10+
11+
describe('AuthService', () => {
12+
let service: AuthService;
13+
let httpMock: HttpTestingController;
14+
let routerSpy: jasmine.Spy;
15+
16+
beforeEach(() => {
17+
TestBed.configureTestingModule({
18+
providers: [
19+
provideHttpClient(),
20+
provideHttpClientTesting(),
21+
AuthService,
22+
{
23+
provide: Router,
24+
useValue: { navigate: jasmine.createSpy('navigate') },
25+
},
26+
],
27+
});
28+
service = TestBed.inject(AuthService);
29+
httpMock = TestBed.inject(HttpTestingController);
30+
routerSpy = spyOn(TestBed.inject(Router), 'navigate');
31+
});
32+
33+
afterEach(() => {
34+
httpMock.verify();
35+
localStorage.clear();
36+
});
37+
38+
describe('isLoggedInLocally()', () => {
39+
it('should return false when no token exists', () => {
40+
expect(service.isLoggedInLocally()).toBe(false);
41+
});
42+
43+
it('should return false when token cannot be decoded', () => {
44+
service.saveToken('invalid.token');
45+
expect(service.isLoggedInLocally()).toBe(false);
46+
});
47+
48+
it('should return true for token with no exp claim', () => {
49+
const token =
50+
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ';
51+
service.saveToken(token);
52+
expect(service.isLoggedInLocally()).toBe(true);
53+
});
54+
55+
it('should return true for non-expired token with exp claim', () => {
56+
const futureExp = Math.floor(Date.now() / 1000) + 3600; // 1 hour in future
57+
const payload = { sub: 'user1', exp: futureExp };
58+
const token = createJwt(payload);
59+
service.saveToken(token);
60+
expect(service.isLoggedInLocally()).toBe(true);
61+
});
62+
63+
it('should return false for expired token', () => {
64+
const pastExp = Math.floor(Date.now() / 1000) - 3600; // 1 hour in past
65+
const payload = { sub: 'user1', exp: pastExp };
66+
const token = createJwt(payload);
67+
service.saveToken(token);
68+
expect(service.isLoggedInLocally()).toBe(false);
69+
});
70+
});
71+
72+
describe('validateTokenWithServer()', () => {
73+
it('should return false when no token exists locally', (done) => {
74+
service.validateTokenWithServer().subscribe((result) => {
75+
expect(result).toBe(false);
76+
done();
77+
});
78+
});
79+
80+
it('should call /auth/refresh and update token on success', (done) => {
81+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
82+
const oldToken = createJwt({ sub: 'user1', exp: futureExp });
83+
const newToken = createJwt({ sub: 'user1', exp: futureExp + 7200 });
84+
service.saveToken(oldToken);
85+
86+
service.validateTokenWithServer().subscribe((result) => {
87+
expect(result).toBe(true);
88+
expect(service.getToken()).toBe(newToken);
89+
done();
90+
});
91+
92+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
93+
expect(req.request.method).toBe('POST');
94+
req.flush({ token: newToken });
95+
});
96+
97+
it('should clear auth state and return false when server responds with 401', (done) => {
98+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
99+
const token = createJwt({ sub: 'user1', exp: futureExp });
100+
service.saveToken(token);
101+
service.saveUsername('testuser');
102+
103+
service.validateTokenWithServer().subscribe((result) => {
104+
expect(result).toBe(false);
105+
expect(localStorage.getItem('token')).toBeNull();
106+
expect(localStorage.getItem('username')).toBeNull();
107+
done();
108+
});
109+
110+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
111+
req.flush(null, { status: 401, statusText: 'Unauthorized' });
112+
});
113+
114+
it('should clear auth state and return false when server responds with 403', (done) => {
115+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
116+
const token = createJwt({ sub: 'user1', exp: futureExp });
117+
service.saveToken(token);
118+
service.saveUsername('testuser');
119+
120+
service.validateTokenWithServer().subscribe((result) => {
121+
expect(result).toBe(false);
122+
expect(localStorage.getItem('token')).toBeNull();
123+
expect(localStorage.getItem('username')).toBeNull();
124+
done();
125+
});
126+
127+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
128+
req.flush(null, { status: 403, statusText: 'Forbidden' });
129+
});
130+
131+
it('should return false on network error', (done) => {
132+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
133+
const token = createJwt({ sub: 'user1', exp: futureExp });
134+
service.saveToken(token);
135+
136+
service.validateTokenWithServer().subscribe((result) => {
137+
expect(result).toBe(false);
138+
done();
139+
});
140+
141+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
142+
req.error(new ErrorEvent('Network error'));
143+
});
144+
145+
it('should handle server 500 error gracefully (fail-closed)', (done) => {
146+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
147+
const token = createJwt({ sub: 'user1', exp: futureExp });
148+
service.saveToken(token);
149+
150+
service.validateTokenWithServer().subscribe((result) => {
151+
expect(result).toBe(false);
152+
done();
153+
});
154+
155+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
156+
req.flush(null, { status: 500, statusText: 'Internal Server Error' });
157+
});
158+
});
159+
160+
describe('Integration: locally valid token that is server-invalid (revoked)', () => {
161+
it('should allow navigation locally but fail server validation on revoked token', (done) => {
162+
const futureExp = Math.floor(Date.now() / 1000) + 3600;
163+
const token = createJwt({ sub: 'user1', exp: futureExp });
164+
service.saveToken(token);
165+
166+
expect(service.isLoggedInLocally()).toBe(true);
167+
168+
service.validateTokenWithServer().subscribe((result) => {
169+
expect(result).toBe(false);
170+
expect(localStorage.getItem('token')).toBeNull();
171+
done();
172+
});
173+
174+
const req = httpMock.expectOne(`${environment.mainApiUrl}/auth/refresh`);
175+
req.flush(null, { status: 401, statusText: 'Unauthorized' });
176+
});
177+
});
178+
});
179+
180+
function createJwt(payload: any): string {
181+
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
182+
const payloadStr = btoa(JSON.stringify(payload));
183+
const signature = 'dummy_signature';
184+
return `${header}.${payloadStr}.${signature}`;
185+
}

0 commit comments

Comments
 (0)