feat(auth): refactor authentication to use cookies for token management and add CookieService

This commit is contained in:
ncoronel 2026-07-03 14:16:22 -03:00
parent 3971970975
commit 1df6964859
4 changed files with 134 additions and 56 deletions

View File

@ -1,10 +1,6 @@
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'checkout',
renderMode: RenderMode.Client
},
{
path: '**',
renderMode: RenderMode.Server

View File

@ -1,20 +1,37 @@
import { PLATFORM_ID } from '@angular/core';
import { PLATFORM_ID, TransferState } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { environment } from '../../../../environments/environment';
import { AuthService } from './auth.service';
import { CookieService } from '../cookie/cookie.service';
describe('AuthService', () => {
let cookieStore: Record<string, string> = {};
beforeEach(() => {
window.localStorage.clear();
cookieStore = {};
TestBed.resetTestingModule();
});
function createCookieServiceStub() {
return {
get: (name: string) => cookieStore[name] || null,
set: (name: string, value: string) => { cookieStore[name] = value; },
delete: (name: string) => { delete cookieStore[name]; }
};
}
it('stores token and user on successful login', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@ -40,16 +57,22 @@ describe('AuthService', () => {
expect(service.token()).toBe('plain-text-token');
expect(service.user()?.email).toBe('ada@example.com');
expect(service.isAuthenticated()).toBe(true);
expect(window.localStorage.getItem('shopit.auth.token')).toBe('plain-text-token');
expect(cookieStore['shopit.auth.token']).toBe('plain-text-token');
httpController.verify();
});
it('hydrates token from localStorage and loads the current user during bootstrap', async () => {
window.localStorage.setItem('shopit.auth.token', 'persisted-token');
it('hydrates token from cookies and loads the current user during bootstrap', async () => {
cookieStore['shopit.auth.token'] = 'persisted-token';
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@ -73,10 +96,16 @@ describe('AuthService', () => {
});
it('clears the session when bootstrap receives 401 from /me', async () => {
window.localStorage.setItem('shopit.auth.token', 'expired-token');
cookieStore['shopit.auth.token'] = 'expired-token';
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@ -91,14 +120,20 @@ describe('AuthService', () => {
expect(service.user()).toBeNull();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('registers without creating a session', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@ -135,7 +170,13 @@ describe('AuthService', () => {
it('clears local session even when logout request fails', () => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting(), AuthService]
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: CookieService, useValue: createCookieServiceStub() },
TransferState
]
});
const service = TestBed.inject(AuthService);
@ -163,26 +204,8 @@ describe('AuthService', () => {
expect(service.token()).toBeNull();
expect(service.user()).toBeNull();
expect(window.localStorage.getItem('shopit.auth.token')).toBeNull();
expect(cookieStore['shopit.auth.token']).toBeUndefined();
httpController.verify();
});
it('does not access localStorage on the server', () => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(),
provideHttpClientTesting(),
AuthService,
{ provide: PLATFORM_ID, useValue: 'server' }
]
});
const service = TestBed.inject(AuthService);
service.hydrateSession();
expect(service.token()).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});
});

View File

@ -1,6 +1,6 @@
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { isPlatformBrowser } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { computed, inject, Injectable, PLATFORM_ID, signal, TransferState, makeStateKey } from '@angular/core';
import { firstValueFrom, map, Observable, of, tap } from 'rxjs';
import { environment } from '../../../../environments/environment';
@ -11,8 +11,10 @@ import {
RegisterPayload,
RegisterResponse
} from './auth.interfaces';
import { CookieService } from '../cookie/cookie.service';
const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token';
const AUTH_TOKEN_COOKIE_KEY = 'shopit.auth.token';
const AUTH_USER_SSR_STATE_KEY = makeStateKey<AuthUser>('shopit.auth.user');
@Injectable({
providedIn: 'root'
@ -20,6 +22,8 @@ const AUTH_TOKEN_STORAGE_KEY = 'shopit.auth.token';
export class AuthService {
private readonly http = inject(HttpClient);
private readonly platformId = inject(PLATFORM_ID);
private readonly cookieService = inject(CookieService);
private readonly transferState = inject(TransferState);
private readonly userState = signal<AuthUser | null>(null);
private readonly tokenState = signal<string | null>(null);
@ -60,8 +64,18 @@ export class AuthService {
return;
}
const transferredUser = this.transferState.get(AUTH_USER_SSR_STATE_KEY, null);
if (transferredUser) {
this.transferState.remove(AUTH_USER_SSR_STATE_KEY);
this.userState.set(transferredUser);
return;
}
try {
await firstValueFrom(this.loadCurrentUser());
const user = await firstValueFrom(this.loadCurrentUser());
if (isPlatformServer(this.platformId)) {
this.transferState.set(AUTH_USER_SSR_STATE_KEY, user);
}
} catch (error) {
if (this.isUnauthorizedError(error)) {
this.clearSession();
@ -79,34 +93,20 @@ export class AuthService {
}
hydrateSession(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const token = window.localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
const token = this.cookieService.get(AUTH_TOKEN_COOKIE_KEY);
this.tokenState.set(token);
}
clearSession(): void {
this.userState.set(null);
this.tokenState.set(null);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
this.cookieService.delete(AUTH_TOKEN_COOKIE_KEY);
}
private applyAuthenticatedState(token: string, user: AuthUser): void {
this.tokenState.set(token);
this.userState.set(user);
if (!isPlatformBrowser(this.platformId)) {
return;
}
window.localStorage.setItem(AUTH_TOKEN_STORAGE_KEY, token);
this.cookieService.set(AUTH_TOKEN_COOKIE_KEY, token);
}
private isUnauthorizedError(error: unknown): error is HttpErrorResponse {

View File

@ -0,0 +1,59 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID, REQUEST } from '@angular/core';
import { DOCUMENT } from '@angular/common';
@Injectable({
providedIn: 'root'
})
export class CookieService {
private readonly platformId = inject(PLATFORM_ID);
private readonly document = inject(DOCUMENT);
private readonly request = inject(REQUEST, { optional: true });
get(name: string): string | null {
if (isPlatformBrowser(this.platformId)) {
return this.getCookieFromString(name, this.document.cookie);
}
if (this.request) {
const cookieHeader = this.request.headers.get('cookie') || '';
return this.getCookieFromString(name, cookieHeader);
}
return null;
}
set(name: string, value: string, days = 7): void {
if (isPlatformBrowser(this.platformId)) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
this.document.cookie = `${name}=${value};${expires};path=/;SameSite=Lax`;
}
}
delete(name: string): void {
if (isPlatformBrowser(this.platformId)) {
this.document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
}
private getCookieFromString(name: string, cookieString: string): string | null {
if (!cookieString) {
return null;
}
const nameEQ = `${name}=`;
const ca = cookieString.split(';');
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') {
c = c.substring(1, c.length);
}
if (c.indexOf(nameEQ) === 0) {
return c.substring(nameEQ.length, c.length);
}
}
return null;
}
}