fix(api-key): enforce requested product binding
This commit is contained in:
parent
daec38faf7
commit
841d2f5129
@ -81,6 +81,30 @@ describe('api key auth', () => {
|
|||||||
expect(res.statusCode).toBe(403);
|
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 () => {
|
it('rate limits per api key and action key', async () => {
|
||||||
process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({
|
process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({
|
||||||
'jobs:write': {
|
'jobs:write': {
|
||||||
|
|||||||
@ -118,6 +118,13 @@ function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []):
|
|||||||
throw new UnauthorizedError('API key required');
|
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 (
|
if (
|
||||||
requiredScopes.length > 0 &&
|
requiredScopes.length > 0 &&
|
||||||
!requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope))
|
!requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope))
|
||||||
@ -181,17 +188,12 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
|
|||||||
if (!rawKey) return;
|
if (!rawKey) return;
|
||||||
|
|
||||||
const prefix = rawKey.slice(0, 12);
|
const prefix = rawKey.slice(0, 12);
|
||||||
const productIdHeader = req.headers['x-product-id'];
|
|
||||||
const filter: Record<string, unknown> = {
|
const filter: Record<string, unknown> = {
|
||||||
prefix,
|
prefix,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
expiresAt: { $gte: new Date().toISOString() },
|
expiresAt: { $gte: new Date().toISOString() },
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof productIdHeader === 'string' && productIdHeader.length > 0) {
|
|
||||||
filter.productId = productIdHeader;
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = await tokenCollection().findMany({
|
const candidates = await tokenCollection().findMany({
|
||||||
filter,
|
filter,
|
||||||
limit: 10,
|
limit: 10,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user