feat(auth): update session handling on 401 responses and refactor error propagation
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||||
import { computed, signal } from '@angular/core';
|
import { computed, signal } from '@angular/core';
|
||||||
@@ -69,7 +69,7 @@ describe('authInterceptor', () => {
|
|||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the local session when an authenticated request returns 401', () => {
|
it('propagates a 401 without clearing the local session', () => {
|
||||||
const authService = createAuthServiceStub('expired-token');
|
const authService = createAuthServiceStub('expired-token');
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -83,14 +83,21 @@ describe('authInterceptor', () => {
|
|||||||
const client = TestBed.inject(HttpClient);
|
const client = TestBed.inject(HttpClient);
|
||||||
const httpController = TestBed.inject(HttpTestingController);
|
const httpController = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
let receivedError: unknown;
|
||||||
|
let receivedStatus: number | null = null;
|
||||||
client.get(`${environment.url}me`).subscribe({
|
client.get(`${environment.url}me`).subscribe({
|
||||||
error: () => undefined
|
error: (error: HttpErrorResponse) => {
|
||||||
|
receivedError = error;
|
||||||
|
receivedStatus = error.status;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const request = httpController.expectOne(`${environment.url}me`);
|
const request = httpController.expectOne(`${environment.url}me`);
|
||||||
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
expect(authService.clearSession).toHaveBeenCalledTimes(1);
|
expect(receivedError).toBeInstanceOf(HttpErrorResponse);
|
||||||
|
expect(receivedStatus).toBe(401);
|
||||||
|
expect(authService.clearSession).not.toHaveBeenCalled();
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import {
|
import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
|
||||||
HttpErrorResponse,
|
|
||||||
HttpEvent,
|
|
||||||
HttpHandlerFn,
|
|
||||||
HttpInterceptorFn,
|
|
||||||
HttpRequest
|
|
||||||
} from '@angular/common/http';
|
|
||||||
import { inject } from '@angular/core';
|
import { inject } from '@angular/core';
|
||||||
import { Observable, catchError, throwError } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
@@ -25,13 +19,5 @@ export const authInterceptor: HttpInterceptorFn = (
|
|||||||
})
|
})
|
||||||
: request;
|
: request;
|
||||||
|
|
||||||
return next(authenticatedRequest).pipe(
|
return next(authenticatedRequest);
|
||||||
catchError((error: unknown) => {
|
|
||||||
if (error instanceof HttpErrorResponse && error.status === 401 && token) {
|
|
||||||
authService.clearSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
return throwError(() => error);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ describe('AuthService', () => {
|
|||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears the session when bootstrap receives 401 from /me', async () => {
|
it('propagates a 401 from /me without clearing the session', async () => {
|
||||||
cookieStore['shopit.auth.token'] = 'expired-token';
|
cookieStore['shopit.auth.token'] = 'expired-token';
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -116,11 +116,11 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
request.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
await expect(bootstrapPromise).resolves.toBeUndefined();
|
await expect(bootstrapPromise).rejects.toMatchObject({ status: 401 });
|
||||||
expect(service.user()).toBeNull();
|
expect(service.user()).toBeNull();
|
||||||
expect(service.token()).toBeNull();
|
expect(service.token()).toBe('expired-token');
|
||||||
expect(service.isAuthenticated()).toBe(false);
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
expect(cookieStore['shopit.auth.token']).toBeUndefined();
|
expect(cookieStore['shopit.auth.token']).toBe('expired-token');
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
@@ -168,7 +168,7 @@ describe('AuthService', () => {
|
|||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clears local session even when logout request fails', () => {
|
it('propagates logout errors without clearing the local session', () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
@@ -202,9 +202,9 @@ describe('AuthService', () => {
|
|||||||
expect(logoutRequest.request.method).toBe('POST');
|
expect(logoutRequest.request.method).toBe('POST');
|
||||||
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
logoutRequest.flush({ message: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||||
|
|
||||||
expect(service.token()).toBeNull();
|
expect(service.token()).toBe('plain-text-token');
|
||||||
expect(service.user()).toBeNull();
|
expect(service.user()?.email).toBe('ada@example.com');
|
||||||
expect(cookieStore['shopit.auth.token']).toBeUndefined();
|
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
|
||||||
|
|
||||||
httpController.verify();
|
httpController.verify();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
|
||||||
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
|
||||||
@@ -57,10 +57,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
|
return this.http.post<void>(`${environment.url}logout`, {}).pipe(
|
||||||
tap({
|
tap(() => this.clearSession())
|
||||||
next: () => this.clearSession(),
|
|
||||||
error: () => this.clearSession()
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,19 +75,10 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await firstValueFrom(this.loadCurrentUser());
|
const user = await firstValueFrom(this.loadCurrentUser());
|
||||||
if (isPlatformServer(this.platformId)) {
|
if (isPlatformServer(this.platformId)) {
|
||||||
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
if (this.isUnauthorizedError(error)) {
|
|
||||||
this.clearSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCurrentUser(): Observable<AuthUser> {
|
loadCurrentUser(): Observable<AuthUser> {
|
||||||
@@ -115,8 +103,4 @@ export class AuthService {
|
|||||||
this.userState.set(user);
|
this.userState.set(user);
|
||||||
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
|
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {
|
|
||||||
return error instanceof HttpErrorResponse && error.status === 401;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ export class CookieService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
set(name: string, value: string, days = 7): void {
|
set(name: string, value: string): void {
|
||||||
if (isPlatformBrowser(this.platformId)) {
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
const date = new Date();
|
this.document.cookie = `${name}=${value};path=/;SameSite=Lax`;
|
||||||
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
||||||
const expires = `expires=${date.toUTCString()}`;
|
|
||||||
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user