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) {}
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();
},
});

View File

@ -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<any> {
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`, {}, {

View File

@ -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<any> = new BehaviorSubject<any>(null);
constructor(private authService: AuthService) {}
@ -21,8 +22,11 @@ export class JwtInterceptor implements HttpInterceptor {
request: HttpRequest<any>,
next: HttpHandler
): 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) {
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<any>, next: HttpHandler) {
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
if (!this.isRefreshing) {
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(
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)))
);
}
}

View File

@ -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<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 {
userId: payload.sub,
username: payload.username,