diff --git a/services/platform-service/src/lib/api-key-auth.test.ts b/services/platform-service/src/lib/api-key-auth.test.ts new file mode 100644 index 00000000..0a46a735 --- /dev/null +++ b/services/platform-service/src/lib/api-key-auth.test.ts @@ -0,0 +1,146 @@ +import Fastify from 'fastify'; +import bcrypt from 'bcryptjs'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { MemoryDatastoreProvider } from '@bytelyst/datastore'; +import { setProvider } from './datastore.js'; +import { registerOptionalApiKeyContext, requireJwtOrApiKey } from './api-key-auth.js'; + +const rawApiKey = `wai_${'a'.repeat(64)}`; + +async function seedApiKeyToken(scopes: string[] = ['jobs:read']) { + const provider = new MemoryDatastoreProvider(); + setProvider(provider); + const collection = provider.getCollection('api_tokens', '/id'); + await collection.create({ + id: 'tok_api_1', + productId: 'lysnrai', + userId: 'svc_jobs', + userName: 'Jobs Service', + prefix: rawApiKey.slice(0, 12), + tokenHash: await bcrypt.hash(rawApiKey, 10), + status: 'active', + scopes, + expiresAt: '2099-01-01T00:00:00.000Z', + lastUsed: null, + }); +} + +describe('api key auth', () => { + beforeEach(() => { + delete process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + }); + + it('attaches apiKeyAuth from x-api-key', async () => { + await seedApiKeyToken(); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + const actor = requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:read'], + rateLimitKey: 'jobs:read', + }); + return actor; + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ + actorId: 'svc_jobs', + productId: 'lysnrai', + source: 'api_key', + }); + }); + + it('rejects api key without required scopes', async () => { + await seedApiKeyToken(['jobs:read']); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:write'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + headers: { + 'x-api-key': rawApiKey, + }, + }); + + expect(res.statusCode).toBe(403); + }); + + it('rate limits per api key and action key', async () => { + process.env.API_KEY_RATE_LIMIT_CONFIG_JSON = JSON.stringify({ + 'jobs:write': { + maxRequests: 1, + windowSeconds: 60, + }, + }); + + await seedApiKeyToken(['jobs:write']); + const app = Fastify(); + await registerOptionalApiKeyContext(app); + + app.post('/probe', async req => { + return requireJwtOrApiKey(req, { + apiKeyScopes: ['jobs:write'], + rateLimitKey: 'jobs:write', + }); + }); + + const first = await app.inject({ + method: 'POST', + url: '/probe', + headers: { 'x-api-key': rawApiKey }, + }); + expect(first.statusCode).toBe(200); + + const second = await app.inject({ + method: 'POST', + url: '/probe', + headers: { 'x-api-key': rawApiKey }, + }); + expect(second.statusCode).toBe(429); + }); + + it('allows JWT callers when configured', async () => { + const app = Fastify(); + await registerOptionalApiKeyContext(app); + app.addHook('onRequest', async req => { + req.jwtPayload = { + sub: 'admin_1', + role: 'admin', + productId: 'lysnrai', + }; + }); + + app.get('/probe', async req => { + return requireJwtOrApiKey(req, { + jwtRoles: ['admin'], + }); + }); + + const res = await app.inject({ + method: 'GET', + url: '/probe', + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toMatchObject({ + actorId: 'admin_1', + source: 'jwt', + }); + }); +}); diff --git a/services/platform-service/src/lib/api-key-auth.ts b/services/platform-service/src/lib/api-key-auth.ts new file mode 100644 index 00000000..4c832980 --- /dev/null +++ b/services/platform-service/src/lib/api-key-auth.ts @@ -0,0 +1,217 @@ +import bcrypt from 'bcryptjs'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { ForbiddenError, TooManyRequestsError, UnauthorizedError } from './errors.js'; +import { getCollection } from './datastore.js'; +import * as rateLimitStore from '../modules/ratelimit/store.js'; +import type { RateLimitRule } from '../modules/ratelimit/types.js'; + +interface ApiTokenLookupDoc { + id: string; + productId: string; + userId: string; + userName: string; + prefix: string; + tokenHash: string; + status: 'active' | 'revoked' | 'expired'; + scopes: string[]; + expiresAt: string; + lastUsed: string | null; +} + +export interface ApiKeyAuthPayload { + tokenId: string; + productId: string; + userId: string; + userName: string; + scopes: string[]; + prefix: string; +} + +interface AccessOptions { + allowJwt?: boolean; + jwtRoles?: string[]; + apiKeyScopes?: string[]; + rateLimitKey?: string; +} + +interface AccessActor { + actorId: string; + productId: string; + source: 'jwt' | 'api_key'; +} + +interface ApiKeyRateLimitConfig { + [key: string]: RateLimitRule; +} + +declare module 'fastify' { + interface FastifyRequest { + apiKeyAuth?: ApiKeyAuthPayload; + } +} + +function tokenCollection() { + return getCollection('api_tokens', '/id'); +} + +function extractApiKey(req: FastifyRequest): string | null { + const header = req.headers['x-api-key']; + if (typeof header === 'string' && header.trim().length > 0) { + return header.trim(); + } + + const auth = req.headers.authorization; + if (!auth) return null; + + if (auth.startsWith('ApiKey ')) { + return auth.slice('ApiKey '.length).trim(); + } + + if (auth.startsWith('Bearer wai_')) { + return auth.slice('Bearer '.length).trim(); + } + + return null; +} + +function loadApiKeyRateLimitConfig(): ApiKeyRateLimitConfig { + const defaults: ApiKeyRateLimitConfig = { + 'jobs:read': { maxRequests: 60, windowSeconds: 60 }, + 'jobs:write': { maxRequests: 15, windowSeconds: 60 }, + 'exports:read': { maxRequests: 30, windowSeconds: 60 }, + 'exports:write': { maxRequests: 10, windowSeconds: 60 }, + 'maintenance:read': { maxRequests: 30, windowSeconds: 60 }, + 'maintenance:write': { maxRequests: 10, windowSeconds: 60 }, + 'ip-rules:read': { maxRequests: 30, windowSeconds: 60 }, + 'ip-rules:write': { maxRequests: 10, windowSeconds: 60 }, + }; + + const raw = process.env.API_KEY_RATE_LIMIT_CONFIG_JSON; + if (!raw) return defaults; + + try { + const parsed = JSON.parse(raw) as ApiKeyRateLimitConfig; + return { ...defaults, ...parsed }; + } catch { + return defaults; + } +} + +function tokenHasScope(grantedScopes: string[], requiredScope: string): boolean { + if (grantedScopes.includes('*')) return true; + + return grantedScopes.some(scope => { + if (scope === requiredScope) return true; + if (scope.endsWith('*')) { + const prefix = scope.slice(0, -1); + return requiredScope.startsWith(prefix); + } + return false; + }); +} + +function ensureApiKeyScopes(req: FastifyRequest, requiredScopes: string[] = []): ApiKeyAuthPayload { + const apiKey = req.apiKeyAuth; + if (!apiKey) { + throw new UnauthorizedError('API key required'); + } + + if ( + requiredScopes.length > 0 && + !requiredScopes.every(scope => tokenHasScope(apiKey.scopes, scope)) + ) { + throw new ForbiddenError('API key missing required scopes'); + } + + return apiKey; +} + +function enforceApiKeyRateLimit(req: FastifyRequest, rateLimitKey?: string): void { + if (!rateLimitKey || !req.apiKeyAuth) return; + + const apiKeyRateLimitConfig = loadApiKeyRateLimitConfig(); + const rule = apiKeyRateLimitConfig[rateLimitKey]; + if (!rule) return; + + const compositeKey = `api-key:${req.apiKeyAuth.productId}:${req.apiKeyAuth.tokenId}:${rateLimitKey}`; + const result = rateLimitStore.checkAndRecord(compositeKey, rule); + if (!result.allowed) { + throw new TooManyRequestsError('API key rate limit exceeded', { + retryAfter: Math.ceil((result.retryAfterMs ?? 0) / 1000), + }); + } +} + +export function requireJwtOrApiKey( + req: FastifyRequest, + { allowJwt = false, jwtRoles, apiKeyScopes, rateLimitKey }: AccessOptions = {} +): AccessActor { + const jwt = req.jwtPayload; + if (jwt?.sub) { + if (jwtRoles && jwtRoles.length > 0) { + if (!jwt.role || !jwtRoles.includes(jwt.role)) { + throw new ForbiddenError('Admin access required'); + } + } else if (!allowJwt) { + throw new ForbiddenError('JWT access is not permitted for this route'); + } + + return { + actorId: jwt.sub, + productId: jwt.productId ?? process.env.DEFAULT_PRODUCT_ID ?? 'lysnrai', + source: 'jwt', + }; + } + + const apiKey = ensureApiKeyScopes(req, apiKeyScopes); + enforceApiKeyRateLimit(req, rateLimitKey); + + return { + actorId: apiKey.userId, + productId: apiKey.productId, + source: 'api_key', + }; +} + +export async function registerOptionalApiKeyContext(app: FastifyInstance): Promise { + app.addHook('onRequest', async req => { + const rawKey = extractApiKey(req); + 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, + }); + + for (const candidate of candidates) { + const ok = await bcrypt.compare(rawKey, candidate.tokenHash); + if (!ok) continue; + + req.apiKeyAuth = { + tokenId: candidate.id, + productId: candidate.productId, + userId: candidate.userId, + userName: candidate.userName, + scopes: candidate.scopes, + prefix: candidate.prefix, + }; + + tokenCollection() + .update(candidate.id, candidate.id, { lastUsed: new Date().toISOString() }) + .catch(() => {}); + return; + } + }); +} diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index e4c6a2d4..7bb36aec 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -88,6 +88,7 @@ import { runPendingMigrations } from './migrations/runner.js'; import { registerDiagnosticsSubscribers } from './modules/diagnostics/subscribers.js'; import { registerDeliverySubscribers } from './modules/delivery/subscribers.js'; import { verifyToken } from './modules/auth/jwt.js'; +import { registerOptionalApiKeyContext } from './lib/api-key-auth.js'; await initCosmosIfNeeded(); await loadProductCache(); @@ -118,6 +119,7 @@ const app = await createServiceApp({ await registerOptionalJwtContext(app, { verifyToken: async token => (await verifyToken(token)) as JwtPayload, }); +await registerOptionalApiKeyContext(app); // Register route modules await app.register(productRoutes, { prefix: '/api' });