Skip to content

Commit db64539

Browse files
authored
fix(auth): centralize token handling in auth service (#1534)
1 parent 02cdaef commit db64539

8 files changed

Lines changed: 395 additions & 141 deletions

File tree

frontend/src/app/app.routes.spec.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {describe, expect, it} from 'vitest';
22

33
import {routes} from './app.routes';
4-
import {AuthGuard} from './core/security/auth.guard';
4+
import {AuthChildGuard, AuthGuard} from './core/security/auth.guard';
55
import {BookdropGuard} from './core/security/guards/bookdrop.guard';
66
import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard';
77
import {LibraryStatsGuard} from './core/security/guards/library-stats.guard';
@@ -28,10 +28,11 @@ describe('app routes', () => {
2828
const children = shellRoute?.children ?? [];
2929

3030
expect(children).toHaveLength(17);
31-
expect(children.find(route => route.path === 'dashboard')?.canActivate).toEqual([AuthGuard]);
32-
expect(children.find(route => route.path === 'all-books')?.canActivate).toEqual([AuthGuard]);
33-
expect(children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.canActivate).toEqual([AuthGuard]);
34-
expect(children.find(route => route.path === 'notebook')?.canActivate).toEqual([AuthGuard]);
31+
expect(shellRoute?.canActivateChild).toEqual([AuthChildGuard]);
32+
expect(children.find(route => route.path === 'dashboard')?.canActivate).toBeUndefined();
33+
expect(children.find(route => route.path === 'all-books')?.canActivate).toBeUndefined();
34+
expect(children.find(route => route.path === 'magic-shelf/:magicShelfId/books')?.canActivate).toBeUndefined();
35+
expect(children.find(route => route.path === 'notebook')?.canActivate).toBeUndefined();
3536
expect(typeof children.find(route => route.path === 'all-books')?.loadComponent).toBe('function');
3637
expect(typeof children.find(route => route.path === 'library/:libraryId/books')?.loadComponent).toBe('function');
3738
expect(typeof children.find(route => route.path === 'shelf/:shelfId/books')?.loadComponent).toBe('function');

frontend/src/app/app.routes.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Routes} from '@angular/router';
22
import {AppLayoutComponent} from './shared/layout/layout-main/app.layout.component';
33
import {LoginComponent} from './shared/components/login/login.component';
4-
import {AuthGuard} from './core/security/auth.guard';
4+
import {AuthChildGuard, AuthGuard} from './core/security/auth.guard';
55
import {ChangePasswordComponent} from './shared/components/change-password/change-password.component';
66
import {SetupComponent} from './shared/components/setup/setup.component';
77
import {SetupGuard} from './shared/components/setup/setup.guard';
@@ -34,24 +34,25 @@ export const routes: Routes = [
3434
{
3535
path: '',
3636
component: AppLayoutComponent,
37+
canActivateChild: [AuthChildGuard],
3738
children: [
38-
{path: 'dashboard', component: MainDashboardComponent, canActivate: [AuthGuard]},
39-
{path: 'all-books', loadComponent: loadBookBrowserComponent, canActivate: [AuthGuard]},
40-
{path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent), canActivate: [AuthGuard]},
41-
{path: 'library/:libraryId/books', loadComponent: loadBookBrowserComponent, canActivate: [AuthGuard]},
42-
{path: 'shelf/:shelfId/books', loadComponent: loadBookBrowserComponent, canActivate: [AuthGuard]},
43-
{path: 'unshelved-books', loadComponent: loadBookBrowserComponent, canActivate: [AuthGuard]},
44-
{path: 'series', loadComponent: () => import('./features/series-browser/components/series-browser/series-browser.component').then(m => m.SeriesBrowserComponent), canActivate: [AuthGuard]},
45-
{path: 'series/:seriesName', loadComponent: () => import('./features/book/components/series-page/series-page.component').then(m => m.SeriesPageComponent), canActivate: [AuthGuard]},
46-
{path: 'authors', loadComponent: () => import('./features/author-browser/components/author-browser/author-browser.component').then(m => m.AuthorBrowserComponent), canActivate: [AuthGuard]},
47-
{path: 'author/:authorId', loadComponent: () => import('./features/author-browser/components/author-detail/author-detail.component').then(m => m.AuthorDetailComponent), canActivate: [AuthGuard]},
48-
{path: 'magic-shelf/:magicShelfId/books', loadComponent: loadBookBrowserComponent, canActivate: [AuthGuard]},
49-
{path: 'book/:bookId', loadComponent: () => import('./features/metadata/component/book-metadata-center/book-metadata-center.component').then(m => m.BookMetadataCenterComponent), canActivate: [AuthGuard]},
39+
{path: 'dashboard', component: MainDashboardComponent},
40+
{path: 'all-books', loadComponent: loadBookBrowserComponent},
41+
{path: 'settings', loadComponent: () => import('./features/settings/settings.component').then(m => m.SettingsComponent)},
42+
{path: 'library/:libraryId/books', loadComponent: loadBookBrowserComponent},
43+
{path: 'shelf/:shelfId/books', loadComponent: loadBookBrowserComponent},
44+
{path: 'unshelved-books', loadComponent: loadBookBrowserComponent},
45+
{path: 'series', loadComponent: () => import('./features/series-browser/components/series-browser/series-browser.component').then(m => m.SeriesBrowserComponent)},
46+
{path: 'series/:seriesName', loadComponent: () => import('./features/book/components/series-page/series-page.component').then(m => m.SeriesPageComponent)},
47+
{path: 'authors', loadComponent: () => import('./features/author-browser/components/author-browser/author-browser.component').then(m => m.AuthorBrowserComponent)},
48+
{path: 'author/:authorId', loadComponent: () => import('./features/author-browser/components/author-detail/author-detail.component').then(m => m.AuthorDetailComponent)},
49+
{path: 'magic-shelf/:magicShelfId/books', loadComponent: loadBookBrowserComponent},
50+
{path: 'book/:bookId', loadComponent: () => import('./features/metadata/component/book-metadata-center/book-metadata-center.component').then(m => m.BookMetadataCenterComponent)},
5051
{path: 'bookdrop', loadComponent: () => import('./features/bookdrop/component/bookdrop-file-review/bookdrop-file-review.component').then(m => m.BookdropFileReviewComponent), canActivate: [BookdropGuard]},
5152
{path: 'metadata-manager', loadComponent: () => import('./features/metadata/component/metadata-manager/metadata-manager.component').then(m => m.MetadataManagerComponent), canActivate: [EditMetadataGuard]},
5253
{path: 'library-stats', loadComponent: () => import('./features/stats/component/library-stats/library-stats.component').then(m => m.LibraryStatsComponent), canActivate: [LibraryStatsGuard]},
5354
{path: 'reading-stats', loadComponent: () => import('./features/stats/component/user-stats/user-stats.component').then(m => m.UserStatsComponent), canActivate: [UserStatsGuard]},
54-
{path: 'notebook', loadComponent: () => import('./features/notebook/components/notebook/notebook.component').then(m => m.NotebookComponent), canActivate: [AuthGuard]},
55+
{path: 'notebook', loadComponent: () => import('./features/notebook/components/notebook/notebook.component').then(m => m.NotebookComponent)},
5556
]
5657
},
5758
{

frontend/src/app/core/security/auth-interceptor.service.spec.ts

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@ import { AuthInterceptorService } from './auth-interceptor.service';
1010
describe('AuthInterceptorService', () => {
1111
const authService = {
1212
getInternalAccessToken: vi.fn<() => string | null>(),
13-
internalRefreshToken: vi.fn<() => Observable<{ accessToken: string; refreshToken: string }>>(),
14-
saveInternalTokens: vi.fn<(accessToken: string, refreshToken: string) => void>(),
13+
ensureAccessToken: vi.fn<(options?: {forceRefresh?: boolean}) => Observable<string>>(),
1514
logout: vi.fn<() => void>(),
1615
};
1716

@@ -21,8 +20,7 @@ describe('AuthInterceptorService', () => {
2120
beforeEach(() => {
2221
vi.restoreAllMocks();
2322
authService.getInternalAccessToken.mockReset();
24-
authService.internalRefreshToken.mockReset();
25-
authService.saveInternalTokens.mockReset();
23+
authService.ensureAccessToken.mockReset();
2624
authService.logout.mockReset();
2725

2826
TestBed.resetTestingModule();
@@ -82,7 +80,7 @@ describe('AuthInterceptorService', () => {
8280

8381
it('retries a 401 request after a successful refresh', async () => {
8482
authService.getInternalAccessToken.mockReturnValue('expired-token');
85-
authService.internalRefreshToken.mockReturnValue(of({ accessToken: 'fresh-token', refreshToken: 'refresh-token' }));
83+
authService.ensureAccessToken.mockReturnValue(of('fresh-token'));
8684

8785
const next = vi.fn((request: HttpRequest<unknown>) => {
8886
const authHeader = request.headers.get('Authorization');
@@ -97,34 +95,47 @@ describe('AuthInterceptorService', () => {
9795
next
9896
));
9997

100-
expect(authService.internalRefreshToken).toHaveBeenCalledOnce();
101-
expect(authService.saveInternalTokens).toHaveBeenCalledWith('fresh-token', 'refresh-token');
98+
expect(authService.ensureAccessToken).toHaveBeenCalledWith({forceRefresh: true});
10299
expect((response as HttpResponse<string>).body).toBe('Bearer fresh-token');
103100
});
104101

105-
it('retries a 401 request without persisting tokens when the refresh response is incomplete', async () => {
102+
it('logs out when AuthService cannot refresh a complete token pair', async () => {
106103
authService.getInternalAccessToken.mockReturnValue('expired-token');
107-
authService.internalRefreshToken.mockReturnValue(of({ accessToken: 'fresh-token', refreshToken: '' }));
104+
authService.ensureAccessToken.mockReturnValue(throwError(() => new Error('Authentication response did not include a complete token pair')));
105+
const next = vi.fn(() => throwError(() => new HttpErrorResponse({ status: 401 })));
106+
107+
await expect(firstValueFrom(interceptor(
108+
new HttpRequest('GET', `${apiUrl}/books`),
109+
next
110+
))).rejects.toBeInstanceOf(Error);
111+
112+
expect(authService.ensureAccessToken).toHaveBeenCalledWith({forceRefresh: true});
113+
expect(authService.logout).toHaveBeenCalledOnce();
114+
});
115+
116+
it('does not log out when the retried request fails after refresh succeeds', async () => {
117+
authService.getInternalAccessToken.mockReturnValue('expired-token');
118+
authService.ensureAccessToken.mockReturnValue(of('fresh-token'));
108119

109120
const next = vi.fn((request: HttpRequest<unknown>) => {
110121
if (request.headers.get('Authorization') === 'Bearer fresh-token') {
111-
return of(new HttpResponse({ status: 200, body: request.headers.get('Authorization') }));
122+
return throwError(() => new HttpErrorResponse({ status: 500 }));
112123
}
113124
return throwError(() => new HttpErrorResponse({ status: 401 }));
114125
});
115126

116-
const response = await firstValueFrom(interceptor(
127+
await expect(firstValueFrom(interceptor(
117128
new HttpRequest('GET', `${apiUrl}/books`),
118129
next
119-
));
130+
))).rejects.toBeInstanceOf(HttpErrorResponse);
120131

121-
expect(authService.saveInternalTokens).not.toHaveBeenCalled();
122-
expect((response as HttpResponse<string>).body).toBe('Bearer fresh-token');
132+
expect(authService.ensureAccessToken).toHaveBeenCalledWith({forceRefresh: true});
133+
expect(authService.logout).not.toHaveBeenCalled();
123134
});
124135

125136
it('logs out when the refresh request fails', async () => {
126137
authService.getInternalAccessToken.mockReturnValue('expired-token');
127-
authService.internalRefreshToken.mockReturnValue(
138+
authService.ensureAccessToken.mockReturnValue(
128139
throwError(() => new HttpErrorResponse({ status: 500 }))
129140
);
130141

@@ -147,14 +158,14 @@ describe('AuthInterceptorService', () => {
147158
next
148159
))).rejects.toBeInstanceOf(HttpErrorResponse);
149160

150-
expect(authService.internalRefreshToken).not.toHaveBeenCalled();
161+
expect(authService.ensureAccessToken).not.toHaveBeenCalled();
151162
expect(authService.logout).not.toHaveBeenCalled();
152163
});
153164

154-
it('queues concurrent 401s behind a single refresh operation', async () => {
165+
it('delegates concurrent 401 refresh coordination to AuthService', async () => {
155166
authService.getInternalAccessToken.mockReturnValue('expired-token');
156-
const refreshSubject = new Subject<{ accessToken: string; refreshToken: string }>();
157-
authService.internalRefreshToken.mockReturnValue(refreshSubject.asObservable());
167+
const refreshSubject = new Subject<string>();
168+
authService.ensureAccessToken.mockReturnValue(refreshSubject.asObservable());
158169

159170
const next = vi.fn((request: HttpRequest<unknown>) => {
160171
const authHeader = request.headers.get('Authorization');
@@ -167,12 +178,13 @@ describe('AuthInterceptorService', () => {
167178
const firstRequest = firstValueFrom(interceptor(new HttpRequest('GET', `${apiUrl}/books`), next));
168179
const secondRequest = firstValueFrom(interceptor(new HttpRequest('GET', `${apiUrl}/libraries`), next));
169180

170-
refreshSubject.next({ accessToken: 'refreshed-token', refreshToken: 'refresh-token' });
181+
refreshSubject.next('refreshed-token');
171182
refreshSubject.complete();
172183

173184
const [firstResponse, secondResponse] = await Promise.all([firstRequest, secondRequest]);
174185

175-
expect(authService.internalRefreshToken).toHaveBeenCalledOnce();
186+
expect(authService.ensureAccessToken).toHaveBeenCalledTimes(2);
187+
expect(authService.ensureAccessToken).toHaveBeenCalledWith({forceRefresh: true});
176188
expect((firstResponse as HttpResponse<string>).body).toBe('Bearer refreshed-token');
177189
expect((secondResponse as HttpResponse<string>).body).toBe('Bearer refreshed-token');
178190
});

frontend/src/app/core/security/auth-interceptor.service.ts

Lines changed: 9 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,10 @@
11
import { HttpErrorResponse, HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
22
import { inject } from '@angular/core';
3-
import { catchError, filter, switchMap, take } from 'rxjs/operators';
4-
import { BehaviorSubject, Observable, throwError, defer } from 'rxjs';
3+
import { catchError, switchMap } from 'rxjs/operators';
4+
import { Observable, throwError, defer } from 'rxjs';
55
import { AuthService } from '../../shared/service/auth.service';
66
import { API_CONFIG } from '../config/api-config';
77

8-
let isRefreshing = false;
9-
const refreshTokenSubject = new BehaviorSubject<string | null>(null);
10-
118
export const AuthInterceptorService: HttpInterceptorFn = (req, next: HttpHandlerFn) => {
129
const authService = inject(AuthService);
1310

@@ -42,36 +39,14 @@ export const AuthInterceptorService: HttpInterceptorFn = (req, next: HttpHandler
4239

4340
function handle401Error(authService: AuthService, request: HttpRequest<unknown>, next: HttpHandlerFn): Observable<HttpEvent<unknown>> {
4441
return defer(() => {
45-
if (!isRefreshing) {
46-
isRefreshing = true;
47-
refreshTokenSubject.next(null);
48-
49-
return authService.internalRefreshToken().pipe(
50-
switchMap(response => {
51-
isRefreshing = false;
52-
const { accessToken, refreshToken } = response;
53-
if (accessToken && refreshToken) {
54-
authService.saveInternalTokens(accessToken, refreshToken);
55-
refreshTokenSubject.next(accessToken);
56-
}
57-
return next(request.clone({
58-
setHeaders: { Authorization: `Bearer ${accessToken}` }
59-
}));
60-
}),
61-
catchError(err => {
62-
isRefreshing = false;
63-
forceLogout(authService);
64-
return throwError(() => err);
65-
})
66-
);
67-
}
68-
69-
return refreshTokenSubject.pipe(
70-
filter(token => token !== null),
71-
take(1),
72-
switchMap(token =>
42+
return authService.ensureAccessToken({forceRefresh: true}).pipe(
43+
catchError(err => {
44+
forceLogout(authService);
45+
return throwError(() => err);
46+
}),
47+
switchMap(accessToken =>
7348
next(request.clone({
74-
setHeaders: { Authorization: `Bearer ${token}` }
49+
setHeaders: { Authorization: `Bearer ${accessToken}` }
7550
}))
7651
)
7752
);

0 commit comments

Comments
 (0)