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)}`;
|
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();
|
const provider = new MemoryDatastoreProvider();
|
||||||
setProvider(provider);
|
setProvider(provider);
|
||||||
const collection = provider.getCollection('api_tokens', '/id');
|
const collection = provider.getCollection('api_tokens', '/id');
|
||||||
await collection.create({
|
await collection.create({
|
||||||
id: 'tok_api_1',
|
id: 'tok_api_1',
|
||||||
productId: 'lysnrai',
|
productId: overrides.productId ?? 'lysnrai',
|
||||||
userId: 'svc_jobs',
|
userId: 'svc_jobs',
|
||||||
userName: 'Jobs Service',
|
userName: 'Jobs Service',
|
||||||
|
tokenType: overrides.tokenType ?? 'service_api',
|
||||||
|
environment: overrides.environment ?? 'dev',
|
||||||
prefix: rawApiKey.slice(0, 12),
|
prefix: rawApiKey.slice(0, 12),
|
||||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
@ -28,6 +37,7 @@ async function seedApiKeyToken(scopes: string[] = ['jobs:read']) {
|
|||||||
describe('api key auth', () => {
|
describe('api key auth', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
|
delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON;
|
||||||
|
delete process.env.PLATFORM_RUNTIME_ENV;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('attaches apiKeyAuth from x-api-key', async () => {
|
it('attaches apiKeyAuth from x-api-key', async () => {
|
||||||
@ -40,7 +50,7 @@ describe('api key auth', () => {
|
|||||||
apiKeyScopes: ['jobs:read'],
|
apiKeyScopes: ['jobs:read'],
|
||||||
rateLimitKey: 'jobs:read',
|
rateLimitKey: 'jobs:read',
|
||||||
});
|
});
|
||||||
return actor;
|
return { actor, apiKeyAuth: req.apiKeyAuth };
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await app.inject({
|
const res = await app.inject({
|
||||||
@ -53,12 +63,63 @@ describe('api key auth', () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(JSON.parse(res.body)).toMatchObject({
|
expect(JSON.parse(res.body)).toMatchObject({
|
||||||
actorId: 'svc_jobs',
|
actor: {
|
||||||
productId: 'lysnrai',
|
actorId: 'svc_jobs',
|
||||||
source: 'api_key',
|
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 () => {
|
it('rejects api key without required scopes', async () => {
|
||||||
await seedApiKeyToken(['jobs:read']);
|
await seedApiKeyToken(['jobs:read']);
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
|||||||
@ -10,6 +10,8 @@ interface ApiTokenLookupDoc {
|
|||||||
productId: string;
|
productId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
tokenType: 'user_api' | 'product_api' | 'service_api';
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
prefix: string;
|
prefix: string;
|
||||||
tokenHash: string;
|
tokenHash: string;
|
||||||
status: 'active' | 'revoked' | 'expired';
|
status: 'active' | 'revoked' | 'expired';
|
||||||
@ -23,6 +25,8 @@ export interface ApiKeyAuthPayload {
|
|||||||
productId: string;
|
productId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
tokenType: 'product_api' | 'service_api';
|
||||||
|
environment: 'dev' | 'staging' | 'prod';
|
||||||
scopes: string[];
|
scopes: string[];
|
||||||
prefix: 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 {
|
function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload {
|
||||||
const apiKey = req.apiKeyAuth;
|
const apiKey = req.apiKeyAuth;
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@ -203,11 +223,21 @@ export async function registerOptionalApiKeyContext(app: FastifyInstance): Promi
|
|||||||
const ok = await bcrypt.compare(rawKey, candidate.tokenHash);
|
const ok = await bcrypt.compare(rawKey, candidate.tokenHash);
|
||||||
if (!ok) continue;
|
if (!ok) continue;
|
||||||
|
|
||||||
|
if (candidate.tokenType === 'user_api') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate.environment !== getRuntimeEnvironment()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
req.apiKeyAuth = {
|
req.apiKeyAuth = {
|
||||||
tokenId: candidate.id,
|
tokenId: candidate.id,
|
||||||
productId: candidate.productId,
|
productId: candidate.productId,
|
||||||
userId: candidate.userId,
|
userId: candidate.userId,
|
||||||
userName: candidate.userName,
|
userName: candidate.userName,
|
||||||
|
tokenType: candidate.tokenType,
|
||||||
|
environment: candidate.environment,
|
||||||
scopes: candidate.scopes,
|
scopes: candidate.scopes,
|
||||||
prefix: candidate.prefix,
|
prefix: candidate.prefix,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -35,6 +35,8 @@ async function seedApiKey(scopes: string[]) {
|
|||||||
productId: 'lysnrai',
|
productId: 'lysnrai',
|
||||||
userId: 'svc_jobs',
|
userId: 'svc_jobs',
|
||||||
userName: 'Jobs Service',
|
userName: 'Jobs Service',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'dev',
|
||||||
prefix: rawApiKey.slice(0, 12),
|
prefix: rawApiKey.slice(0, 12),
|
||||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@ -24,6 +24,8 @@ async function seedApiKey(scopes: string[]) {
|
|||||||
productId: 'lysnrai',
|
productId: 'lysnrai',
|
||||||
userId: 'svc_runs',
|
userId: 'svc_runs',
|
||||||
userName: 'Runs Service',
|
userName: 'Runs Service',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'dev',
|
||||||
prefix: rawApiKey.slice(0, 12),
|
prefix: rawApiKey.slice(0, 12),
|
||||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@ -32,6 +32,8 @@ async function seedApiKey(scopes: string[]) {
|
|||||||
productId: 'lysnrai',
|
productId: 'lysnrai',
|
||||||
userId: 'svc_webhooks',
|
userId: 'svc_webhooks',
|
||||||
userName: 'Webhook Service',
|
userName: 'Webhook Service',
|
||||||
|
tokenType: 'service_api',
|
||||||
|
environment: 'dev',
|
||||||
prefix: rawApiKey.slice(0, 12),
|
prefix: rawApiKey.slice(0, 12),
|
||||||
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
tokenHash: await bcrypt.hash(rawApiKey, 10),
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user