feat(api-key): log auth and throttling rejects

This commit is contained in:
root 2026-03-15 09:06:51 +00:00
parent 798c1b9fad
commit 95261acb92

View File

@ -59,6 +59,25 @@ function tokenCollection() {
return getCollection<ApiTokenLookupDoc>('api_tokens', '/id'); return getCollection<ApiTokenLookupDoc>('api_tokens', '/id');
} }
function logApiKeyWarning(
req: FastifyRequest,
event: string,
details: Record<string, unknown> = {}
): void {
req.log.warn(
{
event,
requestId: req.id,
productIdHeader: req.headers['x-product-id'],
apiKeyPrefix: req.apiKeyAuth?.prefix,
apiKeyTokenId: req.apiKeyAuth?.tokenId,
apiKeyProductId: req.apiKeyAuth?.productId,
...details,
},
'[api-key] access rejected'
);
}
function extractApiKey(req: FastifyRequest): string | null { function extractApiKey(req: FastifyRequest): string | null {
const header = req.headers['x-api-key']; const header = req.headers['x-api-key'];
if (typeof header === 'string' && header.trim().length > 0) { if (typeof header === 'string' && header.trim().length > 0) {
@ -163,12 +182,16 @@ function ensureApiKeyScopes(
): ApiKeyAuthPayload { ): ApiKeyAuthPayload {
const apiKey = req.apiKeyAuth; const apiKey = req.apiKeyAuth;
if (!apiKey) { if (!apiKey) {
logApiKeyWarning(req, 'missing_api_key_context');
throw new UnauthorizedError('API key required'); throw new UnauthorizedError('API key required');
} }
const productIdHeader = req.headers['x-product-id']; const productIdHeader = req.headers['x-product-id'];
if (typeof productIdHeader === 'string' && productIdHeader.length > 0) { if (typeof productIdHeader === 'string' && productIdHeader.length > 0) {
if (productIdHeader !== apiKey.productId) { if (productIdHeader !== apiKey.productId) {
logApiKeyWarning(req, 'product_mismatch', {
requestedProductId: productIdHeader,
});
throw new ForbiddenError('API key is not valid for the requested product'); throw new ForbiddenError('API key is not valid for the requested product');
} }
} }
@ -177,11 +200,19 @@ function ensureApiKeyScopes(
requiredScopes.length > 0 && requiredScopes.length > 0 &&
!requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope)) !requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope))
) { ) {
logApiKeyWarning(req, 'missing_scope', {
requiredScopes,
grantedScopes: apiKey.scopes,
});
throw new ForbiddenError('API key missing required scopes'); throw new ForbiddenError('API key missing required scopes');
} }
if (allowedTokenTypes && allowedTokenTypes.length > 0) { if (allowedTokenTypes && allowedTokenTypes.length > 0) {
if (!allowedTokenTypes.includes(apiKey.tokenType)) { if (!allowedTokenTypes.includes(apiKey.tokenType)) {
logApiKeyWarning(req, 'token_type_not_permitted', {
allowedTokenTypes,
tokenType: apiKey.tokenType,
});
throw new ForbiddenError('API key token type is not permitted for this route'); throw new ForbiddenError('API key token type is not permitted for this route');
} }
} }
@ -199,6 +230,10 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`; const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`;
const keyResult = rateLimitStore.checkAndRecord(compositeKey, keyRule); const keyResult = rateLimitStore.checkAndRecord(compositeKey, keyRule);
if (!keyResult.allowed) { if (!keyResult.allowed) {
logApiKeyWarning(req, 'token_rate_limited', {
rateLimitKey,
retryAfterMs: keyResult.retryAfterMs,
});
throw new TooManyRequestsError('API key rate limit exceeded', { throw new TooManyRequestsError('API key rate limit exceeded', {
retryAfter: Math.ceil((keyResult.retryAfterMs ?? 0) / 1000), retryAfter: Math.ceil((keyResult.retryAfterMs ?? 0) / 1000),
}); });
@ -210,6 +245,10 @@ function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): voi
const productCompositeKey = `api-key-product:${req.apiKeyAuth.productId}:${rateLimitKey}`; const productCompositeKey = `api-key-product:${req.apiKeyAuth.productId}:${rateLimitKey}`;
const productResult = rateLimitStore.checkAndRecord(productCompositeKey, productRule); const productResult = rateLimitStore.checkAndRecord(productCompositeKey, productRule);
if (!productResult.allowed) { if (!productResult.allowed) {
logApiKeyWarning(req, 'product_rate_limited', {
rateLimitKey,
retryAfterMs: productResult.retryAfterMs,
});
throw new TooManyRequestsError('Product API key rate limit exceeded', { throw new TooManyRequestsError('Product API key rate limit exceeded', {
retryAfter: Math.ceil((productResult.retryAfterMs ?? 0) / 1000), retryAfter: Math.ceil((productResult.retryAfterMs ?? 0) / 1000),
}); });
@ -264,15 +303,19 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
limit: 10, limit: 10,
}); });
let rejectionReason: string | null = null;
for (const candidate of candidates) { for (const candidate of candidates) {
const ok = await bcrypt.compare(rawKey, candidate.tokenHash); const ok = await bcrypt.compare(rawKey, candidate.tokenHash);
if (!ok) continue; if (!ok) continue;
if (candidate.tokenType === 'user_api') { if (candidate.tokenType === 'user_api') {
rejectionReason = 'user_api_not_allowed';
continue; continue;
} }
if (candidate.environment !== getRuntimeEnvironment()) { if (candidate.environment !== getRuntimeEnvironment()) {
rejectionReason = 'environment_mismatch';
continue; continue;
} }
@ -292,5 +335,15 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
.catch(() => {}); .catch(() => {});
return; return;
} }
req.log.warn(
{
event: rejectionReason ?? 'invalid_api_key',
requestId: req.id,
apiKeyPrefix: prefix,
productIdHeader: req.headers['x-product-id'],
},
'[api-key] authentication failed'
);
}); });
} }