fix(api-key): enforce machine token policy

This commit is contained in:
root 2026-03-15 06:16:15 +00:00
parent 507f0fdd1f
commit eac633e1e7
5 changed files with 103 additions and 6 deletions

View File

@ -7,15 +7,24 @@ import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-aut
const rawApiKey = `wai_${'a'.repeat(64)}`;
async function seedApiKeyToken(scopes: string[] = ['jobs:read']) {
async function seedApiKeyToken(
scopes: string[] = ['jobs:read'],
overrides: Partial<{
tokenType: 'user_api' | 'product_api' | 'service_api';
environment: 'dev' | 'staging' | 'prod';
productId: string;
}> = {}
) {
const provider = new MemoryDatastoreProvider();
setProvider(provider);
const collection = provider.getCollection('api_tokens', '/id');
await collection.create({
id: 'tok_api_1',
productId: 'lysnrai',
productId: overrides.productId ?? 'lysnrai',
userId: 'svc_jobs',
userName: 'Jobs Service',
tokenType: overrides.tokenType ?? 'service_api',
environment: overrides.environment ?? 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',
@ -28,6 +37,7 @@ async function seedApiKeyToken(scopes: string[] = ['jobs:read']) {
describe('api key auth', () => {
beforeEach(() => {
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
delete process.env.PLATFORM_RUNTIME_ENV;
});
it('attaches apiKeyAuth from x-api-key', async () => {
@ -40,7 +50,7 @@ describe('api key auth', () => {
apiKeyScopes: ['jobs:read'],
rateLimitKey: 'jobs:read',
});
return actor;
return { actor, apiKeyAuth: req.apiKeyAuth };
});
const res = await app.inject({
@ -53,12 +63,63 @@ describe('api key auth', () => {
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toMatchObject({
actorId: 'svc_jobs',
productId: 'lysnrai',
source: 'api_key',
actor: {
actorId: 'svc_jobs',
productId: 'lysnrai',
source: 'api_key',
},
apiKeyAuth: {
tokenType: 'service_api',
environment: 'dev',
},
});
});
it('rejects user_api tokens for machine authentication', async () => {
await seedApiKeyToken(['jobs:read'], { tokenType: 'user_api' });
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,
},
});
expect(res.statusCode).toBe(401);
});
it('rejects api keys issued for another environment', async () => {
process.env.PLATFORM_RUNTIME_ENV = 'prod';
await seedApiKeyToken(['jobs:read'], { environment: 'staging' });
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,
},
});
expect(res.statusCode).toBe(401);
});
it('rejects api key without required scopes', async () => {
await seedApiKeyToken(['jobs:read']);
const app = Fastify();

View File

@ -10,6 +10,8 @@ interface ApiTokenLookupDoc {
productId: string;
userId: string;
userName: string;
tokenType: 'user_api' | 'product_api' | 'service_api';
environment: 'dev' | 'staging' | 'prod';
prefix: string;
tokenHash: string;
status: 'active' | 'revoked' | 'expired';
@ -23,6 +25,8 @@ export interface ApiKeyAuthPayload {
productId: string;
userId: string;
userName: string;
tokenType: 'product_api' | 'service_api';
environment: 'dev' | 'staging' | 'prod';
scopes: string[];
prefix: string;
}
@ -112,6 +116,22 @@ function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean
});
}
function getRuntimeEnvironment(): 'dev' | 'staging' | 'prod' {
const explicit = process.env.PLATFORM_RUNTIME_ENV;
if (explicit === 'dev' || explicit === 'staging' || explicit === 'prod') {
return explicit;
}
switch (process.env.NODE_ENV) {
case 'production':
return 'prod';
case 'test':
return 'dev';
default:
return 'dev';
}
}
function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload {
const apiKey = req.apiKeyAuth;
if (!apiKey) {
@ -203,11 +223,21 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
const ok = await bcrypt.compare(rawKey, candidate.tokenHash);
if (!ok) continue;
if (candidate.tokenType === 'user_api') {
continue;
}
if (candidate.environment !== getRuntimeEnvironment()) {
continue;
}
req.apiKeyAuth = {
tokenId: candidate.id,
productId: candidate.productId,
userId: candidate.userId,
userName: candidate.userName,
tokenType: candidate.tokenType,
environment: candidate.environment,
scopes: candidate.scopes,
prefix: candidate.prefix,
};

View File

@ -35,6 +35,8 @@ async function seedApiKey(scopes: string[]) {
productId: 'lysnrai',
userId: 'svc_jobs',
userName: 'Jobs Service',
tokenType: 'service_api',
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',

View File

@ -24,6 +24,8 @@ async function seedApiKey(scopes: string[]) {
productId: 'lysnrai',
userId: 'svc_runs',
userName: 'Runs Service',
tokenType: 'service_api',
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',

View File

@ -32,6 +32,8 @@ async function seedApiKey(scopes: string[]) {
productId: 'lysnrai',
userId: 'svc_webhooks',
userName: 'Webhook Service',
tokenType: 'service_api',
environment: 'dev',
prefix: rawApiKey.slice(0, 12),
tokenHash: await bcrypt.hash(rawApiKey, 10),
status: 'active',