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