fix(api-key): enforce machine token policy
This commit is contained in:
parent
507f0fdd1f
commit
eac633e1e7
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user