From d11b0c65e006846e260970e6f785dcdf19789853 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Fri, 14 Nov 2025 16:18:07 +0100 Subject: [PATCH] add refresh token --- admin/src/app/app.ts | 13 ++++------ admin/src/app/auth/auth.service.ts | 6 +++-- admin/src/app/auth/jwt.interceptor.ts | 36 ++++++++++++++------------- server/src/auth/jwt.strategy.ts | 10 +++++--- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/admin/src/app/app.ts b/admin/src/app/app.ts index 426c9f9..5b08b31 100644 --- a/admin/src/app/app.ts +++ b/admin/src/app/app.ts @@ -17,18 +17,15 @@ export class App { constructor(private authService: AuthService, private router: Router) {} logout(): void { - // Make a best-effort to log out on the server. - // The client-side logout will run regardless of whether this call - // succeeds or fails. + // With the interceptor fixed, this is now the correct and robust way. + // The error from a failed server logout will propagate here. this.authService.serverSideLogout().subscribe({ - // The server call can succeed or fail, we don't care about the result, - // we just want to ensure the client is logged out. next: () => { - console.info("server logged out") + console.log('Server-side logout successful.'); this.authService.clientSideLogout(); }, - error: () => { - console.error("server failed to log out") + error: (err) => { + console.error('Server-side logout failed, logging out client-side anyway.', err); this.authService.clientSideLogout(); }, }); diff --git a/admin/src/app/auth/auth.service.ts b/admin/src/app/auth/auth.service.ts index 39e32ec..44f2c05 100644 --- a/admin/src/app/auth/auth.service.ts +++ b/admin/src/app/auth/auth.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable, of } from 'rxjs'; +import { Observable, of, throwError } from 'rxjs'; import { tap } from 'rxjs/operators'; import { Router } from '@angular/router'; @@ -40,7 +40,9 @@ export class AuthService { refreshToken(): Observable { const refreshToken = this.getRefreshToken(); if (!refreshToken) { - return of(null); + // If no refresh token is present, logout and return an error. + this.clientSideLogout(); + return throwError(() => new Error('No refresh token available')); } return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, { diff --git a/admin/src/app/auth/jwt.interceptor.ts b/admin/src/app/auth/jwt.interceptor.ts index fc00237..605a83d 100644 --- a/admin/src/app/auth/jwt.interceptor.ts +++ b/admin/src/app/auth/jwt.interceptor.ts @@ -7,12 +7,13 @@ import { HttpErrorResponse, } from '@angular/common/http'; import { Observable, throwError, BehaviorSubject } from 'rxjs'; -import { catchError, switchMap, filter, take } from 'rxjs/operators'; +import { catchError, switchMap, filter, take, finalize } from 'rxjs/operators'; // Import finalize import { AuthService } from './auth.service'; @Injectable() export class JwtInterceptor implements HttpInterceptor { private isRefreshing = false; + // Initialize refreshTokenSubject with null private refreshTokenSubject: BehaviorSubject = new BehaviorSubject(null); constructor(private authService: AuthService) {} @@ -21,8 +22,11 @@ export class JwtInterceptor implements HttpInterceptor { request: HttpRequest, next: HttpHandler ): Observable> { - const accessToken = this.authService.getAccessToken(); + if (request.url.includes('/auth/refresh')) { + return next.handle(request); + } + const accessToken = this.authService.getAccessToken(); if (accessToken) { request = this.addToken(request, accessToken); } @@ -31,41 +35,39 @@ export class JwtInterceptor implements HttpInterceptor { catchError((error) => { if (error instanceof HttpErrorResponse && error.status === 401) { return this.handle401Error(request, next); - } else { - return throwError(() => error); } + return throwError(() => error); }) ); } - private handle401Error(request: HttpRequest, next: HttpHandler) { + private handle401Error(request: HttpRequest, next: HttpHandler): Observable { if (!this.isRefreshing) { this.isRefreshing = true; - this.refreshTokenSubject = new BehaviorSubject(null); + // Reset the refreshTokenSubject to null so that subsequent requests will wait + this.refreshTokenSubject.next(null); return this.authService.refreshToken().pipe( switchMap((token: any) => { - this.isRefreshing = false; this.refreshTokenSubject.next(token.accessToken); return next.handle(this.addToken(request, token.accessToken)); }), catchError((err) => { - this.isRefreshing = false; - this.refreshTokenSubject.error(err); - - // The interceptor's job is done. It failed to refresh. - // It should NOT handle logout. It should just propagate the error. - // The calling service/component will be responsible for the user-facing action. + // If refresh fails, logout the user + this.authService.clientSideLogout(); return throwError(() => err); + }), + finalize(() => { + // When the refresh attempt completes, set isRefreshing to false + this.isRefreshing = false; }) ); } else { + // If a refresh is already in progress, wait for it to complete return this.refreshTokenSubject.pipe( - filter((token) => token != null), + filter(token => token != null), take(1), - switchMap((jwt) => { - return next.handle(this.addToken(request, jwt)); - }) + switchMap(jwt => next.handle(this.addToken(request, jwt))) ); } } diff --git a/server/src/auth/jwt.strategy.ts b/server/src/auth/jwt.strategy.ts index af3b548..1b4fa6f 100644 --- a/server/src/auth/jwt.strategy.ts +++ b/server/src/auth/jwt.strategy.ts @@ -5,16 +5,20 @@ import { ConfigService } from '@nestjs/config'; import { Role } from './role.enum'; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(configService: ConfigService) { +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, + // This is the critical fix: ensure the strategy uses the ConfigService + // to get the secret at runtime. secretOrKey: configService.get('JWT_SECRET') as string, }); } - validate(payload: { sub: string; username: string; roles: Role[] }) { + // The payload is already validated by passport-jwt at this point, + // so we can trust its contents. + validate(payload: { sub: number; username: string; roles: Role[] }) { return { userId: payload.sub, username: payload.username,