diff --git a/src/common/guards/scoped-api-key.guard.spec.ts b/src/common/guards/scoped-api-key.guard.spec.ts new file mode 100644 index 0000000..2fed796 --- /dev/null +++ b/src/common/guards/scoped-api-key.guard.spec.ts @@ -0,0 +1,49 @@ +import { ExecutionContext, ForbiddenException, TooManyRequestsException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { RequireApiKeyScopes, ScopedApiKeyGuard } from "./scoped-api-key.guard"; + +function contextFor(request: { headers: Record }, scopes: string[] = []): ExecutionContext { + const handler = () => undefined; + Reflect.defineMetadata("apiKeyScopes", scopes, handler); + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => handler, + getClass: () => class TestController {}, + } as unknown as ExecutionContext; +} + +describe("ScopedApiKeyGuard", () => { + const key = "test-api-key"; + const config = { get: jest.fn(() => JSON.stringify({ [key]: ["oracle:read"] })) } as unknown as ConfigService; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("accepts a valid key with the required scope", () => { + const guard = new ScopedApiKeyGuard(config, new Reflector()); + const context = contextFor({ headers: { "x-api-key": key } }, ["oracle:read"]); + expect(guard.canActivate(context)).toBe(true); + }); + + it("rejects missing, invalid, and insufficiently scoped keys", () => { + const guard = new ScopedApiKeyGuard(config, new Reflector()); + expect(() => guard.canActivate(contextFor({ headers: {} }))).toThrow(UnauthorizedException); + expect(() => guard.canActivate(contextFor({ headers: { "x-api-key": "wrong" } }))).toThrow(UnauthorizedException); + expect(() => guard.canActivate(contextFor({ headers: { "x-api-key": key } }, ["claims:write"]))).toThrow(ForbiddenException); + }); + + it("limits requests independently per API key", () => { + const guard = new ScopedApiKeyGuard(config, new Reflector()); + const context = contextFor({ headers: { "x-api-key": key } }); + for (let index = 0; index < 60; index += 1) expect(guard.canActivate(context)).toBe(true); + expect(() => guard.canActivate(context)).toThrow(TooManyRequestsException); + }); + + it("exposes the scope decorator for endpoint metadata", () => { + const handler = () => undefined; + RequireApiKeyScopes("oracle:read")(handler, "name", { value: handler }); + expect(Reflect.getMetadata("apiKeyScopes", handler)).toEqual(["oracle:read"]); + }); +}); diff --git a/src/common/guards/scoped-api-key.guard.ts b/src/common/guards/scoped-api-key.guard.ts new file mode 100644 index 0000000..71f1596 --- /dev/null +++ b/src/common/guards/scoped-api-key.guard.ts @@ -0,0 +1,116 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + SetMetadata, + TooManyRequestsException, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Reflector } from "@nestjs/core"; +import { createHash, timingSafeEqual } from "crypto"; + +export const API_KEY_SCOPES = "apiKeyScopes"; +export const RequireApiKeyScopes = (...scopes: string[]) => + SetMetadata(API_KEY_SCOPES, scopes); + +interface KeyDefinition { + key: string; + scopes: string[]; +} + +interface RateWindow { + count: number; + resetAt: number; +} + +/** + * Authenticates API keys configured in API_KEYS and enforces endpoint scopes. + * + * API_KEYS is a JSON object mapping each key to its scopes: + * {"key-value":["oracle:read","claims:write"]} + */ +@Injectable() +export class ScopedApiKeyGuard implements CanActivate { + private readonly windows = new Map(); + private readonly windowMs = 60_000; + private readonly maxRequests = 60; + + constructor( + private readonly config: ConfigService, + private readonly reflector: Reflector, + ) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ + headers: Record; + apiKeyScopes?: string[]; + }>(); + const provided = this.header(request.headers, "x-api-key"); + if (!provided) throw new UnauthorizedException("API key required"); + + const definition = this.findDefinition(provided); + if (!definition) throw new UnauthorizedException("Invalid API key"); + + this.consume(definition.key); + + const required = + this.reflector.getAllAndOverride(API_KEY_SCOPES, [ + context.getHandler(), + context.getClass(), + ]) ?? []; + if (!required.every((scope) => definition.scopes.includes("*") || definition.scopes.includes(scope))) { + throw new ForbiddenException("API key does not have the required scope"); + } + + request.apiKeyScopes = definition.scopes; + return true; + } + + private findDefinition(provided: string): KeyDefinition | undefined { + const raw = this.config.get("API_KEYS"); + if (!raw) return undefined; + + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + return undefined; + } + + for (const [key, scopes] of Object.entries(parsed)) { + if (!Array.isArray(scopes) || !scopes.every((scope) => typeof scope === "string")) continue; + if (this.constantTimeEqual(provided, key)) return { key, scopes: scopes as string[] }; + } + return undefined; + } + + private consume(key: string): void { + const now = Date.now(); + const fingerprint = createHash("sha256").update(key).digest("hex"); + const current = this.windows.get(fingerprint); + if (!current || now >= current.resetAt) { + this.windows.set(fingerprint, { count: 1, resetAt: now + this.windowMs }); + return; + } + if (current.count >= this.maxRequests) { + throw new TooManyRequestsException("API key rate limit exceeded"); + } + current.count += 1; + } + + private constantTimeEqual(left: string, right: string): boolean { + const leftHash = createHash("sha256").update(left).digest(); + const rightHash = createHash("sha256").update(right).digest(); + return timingSafeEqual(leftHash, rightHash); + } + + private header( + headers: Record, + name: string, + ): string | undefined { + const value = headers[name]; + return Array.isArray(value) ? value[0] : value; + } +}