Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/common/guards/scoped-api-key.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> }, 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"]);
});
});
116 changes: 116 additions & 0 deletions src/common/guards/scoped-api-key.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string, RateWindow>();
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<string, string | string[] | undefined>;
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<string[]>(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<string>("API_KEYS");
if (!raw) return undefined;

let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} 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<string, string | string[] | undefined>,
name: string,
): string | undefined {
const value = headers[name];
return Array.isArray(value) ? value[0] : value;
}
}