add refresh token

This commit is contained in:
Roland Schneider 2025-11-14 16:18:07 +01:00
parent 96af8e564b
commit d11b0c65e0
4 changed files with 35 additions and 30 deletions

View File

@ -17,18 +17,15 @@ export class App {
constructor(private authService: AuthService, private router: Router) {} constructor(private authService: AuthService, private router: Router) {}
logout(): void { logout(): void {
// Make a best-effort to log out on the server. // With the interceptor fixed, this is now the correct and robust way.
// The client-side logout will run regardless of whether this call // The error from a failed server logout will propagate here.
// succeeds or fails.
this.authService.serverSideLogout().subscribe({ 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: () => { next: () => {
console.info("server logged out") console.log('Server-side logout successful.');
this.authService.clientSideLogout(); this.authService.clientSideLogout();
}, },
error: () => { error: (err) => {
console.error("server failed to log out") console.error('Server-side logout failed, logging out client-side anyway.', err);
this.authService.clientSideLogout(); this.authService.clientSideLogout();
}, },
}); });

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs'; import { Observable, of, throwError } from 'rxjs';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
@ -40,7 +40,9 @@ export class AuthService {
refreshToken(): Observable<any> { refreshToken(): Observable<any> {
const refreshToken = this.getRefreshToken(); const refreshToken = this.getRefreshToken();
if (!refreshToken) { 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`, {}, { return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, {

View File

@ -7,12 +7,13 @@ import {
HttpErrorResponse, HttpErrorResponse,
} from '@angular/common/http'; } from '@angular/common/http';
import { Observable, throwError, BehaviorSubject } from 'rxjs'; 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'; import { AuthService } from './auth.service';
@Injectable() @Injectable()
export class JwtInterceptor implements HttpInterceptor { export class JwtInterceptor implements HttpInterceptor {
private isRefreshing = false; private isRefreshing = false;
// Initialize refreshTokenSubject with null
private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null); private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
constructor(private authService: AuthService) {} constructor(private authService: AuthService) {}
@ -21,8 +22,11 @@ export class JwtInterceptor implements HttpInterceptor {
request: HttpRequest<any>, request: HttpRequest<any>,
next: HttpHandler next: HttpHandler
): Observable<HttpEvent<any>> { ): Observable<HttpEvent<any>> {
const accessToken = this.authService.getAccessToken(); if (request.url.includes('/auth/refresh')) {
return next.handle(request);
}
const accessToken = this.authService.getAccessToken();
if (accessToken) { if (accessToken) {
request = this.addToken(request, accessToken); request = this.addToken(request, accessToken);
} }
@ -31,41 +35,39 @@ export class JwtInterceptor implements HttpInterceptor {
catchError((error) => { catchError((error) => {
if (error instanceof HttpErrorResponse && error.status === 401) { if (error instanceof HttpErrorResponse && error.status === 401) {
return this.handle401Error(request, next); return this.handle401Error(request, next);
} else {
return throwError(() => error);
} }
return throwError(() => error);
}) })
); );
} }
private handle401Error(request: HttpRequest<any>, next: HttpHandler) { private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
if (!this.isRefreshing) { if (!this.isRefreshing) {
this.isRefreshing = true; this.isRefreshing = true;
this.refreshTokenSubject = new BehaviorSubject<any>(null); // Reset the refreshTokenSubject to null so that subsequent requests will wait
this.refreshTokenSubject.next(null);
return this.authService.refreshToken().pipe( return this.authService.refreshToken().pipe(
switchMap((token: any) => { switchMap((token: any) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(token.accessToken); this.refreshTokenSubject.next(token.accessToken);
return next.handle(this.addToken(request, token.accessToken)); return next.handle(this.addToken(request, token.accessToken));
}), }),
catchError((err) => { catchError((err) => {
this.isRefreshing = false; // If refresh fails, logout the user
this.refreshTokenSubject.error(err); this.authService.clientSideLogout();
// 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.
return throwError(() => err); return throwError(() => err);
}),
finalize(() => {
// When the refresh attempt completes, set isRefreshing to false
this.isRefreshing = false;
}) })
); );
} else { } else {
// If a refresh is already in progress, wait for it to complete
return this.refreshTokenSubject.pipe( return this.refreshTokenSubject.pipe(
filter((token) => token != null), filter(token => token != null),
take(1), take(1),
switchMap((jwt) => { switchMap(jwt => next.handle(this.addToken(request, jwt)))
return next.handle(this.addToken(request, jwt));
})
); );
} }
} }

View File

@ -5,16 +5,20 @@ import { ConfigService } from '@nestjs/config';
import { Role } from './role.enum'; import { Role } from './role.enum';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(configService: ConfigService) { constructor(private configService: ConfigService) {
super({ super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, ignoreExpiration: false,
// This is the critical fix: ensure the strategy uses the ConfigService
// to get the secret at runtime.
secretOrKey: configService.get<string>('JWT_SECRET') as string, secretOrKey: configService.get<string>('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 { return {
userId: payload.sub, userId: payload.sub,
username: payload.username, username: payload.username,