From 7106791372625dda363eeeae8128cf10026c0d02 Mon Sep 17 00:00:00 2001 From: Tetardtek Date: Sun, 5 Apr 2026 07:39:55 +0200 Subject: [PATCH] =?UTF-8?q?refacto:=20AuthGuard=20token=20cache=20?= =?UTF-8?q?=E2=80=94=20memory=20TTL=205min,=20skip=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map avec lazy cleanup. Token en cache et valide → zero HTTP, latence ~0. Expiration ou miss → round-trip SuperOAuth comme avant. 6 tests unitaires couvrent cache hit, miss, expiry, et edge cases. --- backend/src/auth/auth.guard.spec.ts | 153 ++++++++++++++++++++++++++++ backend/src/auth/auth.guard.ts | 32 +++++- 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 backend/src/auth/auth.guard.spec.ts diff --git a/backend/src/auth/auth.guard.spec.ts b/backend/src/auth/auth.guard.spec.ts new file mode 100644 index 0000000..1004d8f --- /dev/null +++ b/backend/src/auth/auth.guard.spec.ts @@ -0,0 +1,153 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AuthGuard } from './auth.guard'; + +// ── helpers ────────────────────────────────────────────────────────── + +const SUPEROAUTH_URL = 'http://oauth.test'; + +const fakeUser = { + id: 42, + nickname: 'tetard', + email: 'tetard@test.com', +}; + +const oauthResponse = { + success: true, + data: { + user: fakeUser, + linkedProviders: [{ avatar: 'https://img.test/avatar.png' }], + }, +}; + +function makeContext(token?: string): ExecutionContext { + const request: any = { + headers: token ? { authorization: `Bearer ${token}` } : {}, + cookies: {}, + }; + return { + switchToHttp: () => ({ + getRequest: () => request, + }), + } as unknown as ExecutionContext; +} + +function makeGuard(): AuthGuard { + const configService = { + get: jest.fn().mockReturnValue(SUPEROAUTH_URL), + } as unknown as ConfigService; + return new AuthGuard(configService); +} + +// ── tests ──────────────────────────────────────────────────────────── + +describe('AuthGuard', () => { + let fetchSpy: jest.SpiedFunction; + + beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => oauthResponse, + } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('calls SuperOAuth when token is not cached', async () => { + const guard = makeGuard(); + const ctx = makeContext('fresh-token'); + + await guard.canActivate(ctx); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + `${SUPEROAUTH_URL}/api/v1/user/profile`, + { headers: { Authorization: 'Bearer fresh-token' } }, + ); + }); + + it('returns cached user without calling SuperOAuth on second call', async () => { + const guard = makeGuard(); + + // First call — populates cache + await guard.canActivate(makeContext('cached-token')); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Second call — should hit cache + const ctx2 = makeContext('cached-token'); + await guard.canActivate(ctx2); + expect(fetchSpy).toHaveBeenCalledTimes(1); // still 1 — no new fetch + + const req = ctx2.switchToHttp().getRequest() as any; + expect(req.user).toEqual({ + id: 42, + nickname: 'tetard', + avatar: 'https://img.test/avatar.png', + }); + }); + + it('calls SuperOAuth again when cached entry has expired', async () => { + const guard = makeGuard(); + + // First call + await guard.canActivate(makeContext('expiring-token')); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Fast-forward time past TTL (5 min + 1 ms) + const realNow = Date.now; + Date.now = jest.fn().mockReturnValue(realNow() + 5 * 60 * 1000 + 1); + + try { + await guard.canActivate(makeContext('expiring-token')); + expect(fetchSpy).toHaveBeenCalledTimes(2); // re-fetched + } finally { + Date.now = realNow; + } + }); + + it('throws UnauthorizedException when no token is provided', async () => { + const guard = makeGuard(); + await expect(guard.canActivate(makeContext())).rejects.toThrow( + UnauthorizedException, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('throws UnauthorizedException when SuperOAuth rejects the token', async () => { + fetchSpy.mockResolvedValueOnce({ + ok: false, + json: async () => ({}), + } as Response); + + const guard = makeGuard(); + await expect(guard.canActivate(makeContext('bad-token'))).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('does not cache a failed introspection', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + json: async () => ({}), + } as Response); + + const guard = makeGuard(); + + // First call fails + await expect(guard.canActivate(makeContext('retry-token'))).rejects.toThrow( + UnauthorizedException, + ); + + // Restore successful response + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => oauthResponse, + } as Response); + + // Second call should hit SuperOAuth again (not cached failure) + await guard.canActivate(makeContext('retry-token')); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/backend/src/auth/auth.guard.ts b/backend/src/auth/auth.guard.ts index 2a6f5a2..66d3cc5 100644 --- a/backend/src/auth/auth.guard.ts +++ b/backend/src/auth/auth.guard.ts @@ -6,8 +6,17 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +interface CacheEntry { + user: any; + expiresAt: number; +} + +const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + @Injectable() export class AuthGuard implements CanActivate { + private readonly cache = new Map(); + constructor(private readonly configService: ConfigService) {} async canActivate(context: ExecutionContext): Promise { @@ -18,7 +27,7 @@ export class AuthGuard implements CanActivate { throw new UnauthorizedException('No token provided'); } - const userInfo = await this.introspect(token); + const userInfo = await this.resolveUser(token); if (!userInfo) { throw new UnauthorizedException('Invalid token'); } @@ -27,6 +36,27 @@ export class AuthGuard implements CanActivate { return true; } + private async resolveUser(token: string): Promise { + const cached = this.cache.get(token); + if (cached && cached.expiresAt > Date.now()) { + return cached.user; + } + + // Lazy cleanup: remove this expired entry + if (cached) { + this.cache.delete(token); + } + + const user = await this.introspect(token); + if (user) { + this.cache.set(token, { + user, + expiresAt: Date.now() + TOKEN_CACHE_TTL_MS, + }); + } + return user; + } + private extractToken(request: any): string | null { const authHeader = request.headers.authorization; if (authHeader?.startsWith('Bearer ')) {