refacto: AuthGuard token cache — memory TTL 5min, skip round-trip
Map<token, {user, expiresAt}> 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.
This commit is contained in:
153
backend/src/auth/auth.guard.spec.ts
Normal file
153
backend/src/auth/auth.guard.spec.ts
Normal file
@@ -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<typeof global.fetch>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,8 +6,17 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
user: any;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOKEN_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
@@ -18,7 +27,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
throw new UnauthorizedException('No token provided');
|
throw new UnauthorizedException('No token provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = await this.introspect(token);
|
const userInfo = await this.resolveUser(token);
|
||||||
if (!userInfo) {
|
if (!userInfo) {
|
||||||
throw new UnauthorizedException('Invalid token');
|
throw new UnauthorizedException('Invalid token');
|
||||||
}
|
}
|
||||||
@@ -27,6 +36,27 @@ export class AuthGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveUser(token: string): Promise<any> {
|
||||||
|
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 {
|
private extractToken(request: any): string | null {
|
||||||
const authHeader = request.headers.authorization;
|
const authHeader = request.headers.authorization;
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
|||||||
Reference in New Issue
Block a user