feat(auth): refactor authentication to use cookies for token management and add CookieService
This commit is contained in:
parent
3971970975
commit
1df6964859
|
|
@ -1,10 +1,6 @@
|
|||
import { RenderMode, ServerRoute } from '@angular/ssr';
|
||||
|
||||
export const serverRoutes: ServerRoute[] = [
|
||||
{
|
||||
path: 'checkout',
|
||||
renderMode: RenderMode.Client
|
||||
},
|
||||
{
|
||||
path: '**',
|
||||
renderMode: RenderMode.Server
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue