feat(api-key): log auth and throttling rejects
This commit is contained in:
parent
798c1b9fad
commit
95261acb92
@ -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'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user