diff --git a/services/platform-service/src/lib/api-key-auth.test.ts b/services/platform-service/src/lib/api-key-auth.test.ts index 0a46a735..3002d912 100644 --- a/services/platform-service/src/lib/api-key-auth.test.ts +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -81,6 +81,30 @@ describe('api key auth', () => { expect(res.statusCode).toBe(403); }); + it('rejects api key when x-product-id targets another product', async () => { + await seedApiKeyToken(['jobs:read']); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:read'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + 'x-product-id': 'chronomind', + }, + }); + + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).error).toBe('Forbidden'); + }); + it('rate limits per api key and action key', async () => { process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({ 'jobs:write': { diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts index 735b576c..6c52a0c5 100644 --- a/services/platform-service/src/lib/api-key-auth.ts +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -118,6 +118,13 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): throw new UnauthorizedError('API key required'); } + const productIdHeader = req.headers['x-product-id']; + if (typeof productIdHeader === 'string' && productIdHeader.length > 0) { + if (productIdHeader !== apiKey.productId) { + throw new ForbiddenError('API key is not valid for the requested product'); + } + } + if ( requiredScopes.length > 0 && !requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope)) @@ -181,17 +188,12 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi if (!rawKey) return; const prefix = rawKey.slice(0, 12); - const productIdHeader = req.headers['x-product-id']; const filter: Record = { prefix, status: 'active', expiresAt: { $gte: new Date().toISOString() }, }; - if (typeof productIdHeader === 'string' && productIdHeader.length > 0) { - filter.productId = productIdHeader; - } - const candidates = await tokenCollection().findMany({ filter, limit: 10,