diff --git a/apps/integration-service/src/integration-controller-authority.test.ts b/apps/integration-service/src/integration-controller-authority.test.ts new file mode 100644 index 00000000..a2e68392 --- /dev/null +++ b/apps/integration-service/src/integration-controller-authority.test.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const controllerSource = readFileSync(resolve(__dirname, 'main.ts'), 'utf8'); + +describe('Integration event tenant authority contract', () => { + it('never accepts the legacy browser-selectable workspace header', () => { + expect(controllerSource).not.toContain("@Headers('x-workspace-id')"); + expect(controllerSource).toContain("@Headers('x-life-os-workspace-id')"); + expect(controllerSource).toContain("@Headers('x-life-os-context-issued-at')"); + expect(controllerSource).toContain("@Headers('x-life-os-context-signature')"); + expect(controllerSource).toContain('INTEGRATION_GATEWAY_CONTEXT_SECRET'); + expect(controllerSource).toContain('requireTrustedWorkspaceContext('); + }); +}); diff --git a/apps/integration-service/src/main.ts b/apps/integration-service/src/main.ts index d81d0dc7..bc12b3d1 100644 --- a/apps/integration-service/src/main.ts +++ b/apps/integration-service/src/main.ts @@ -1,4 +1,5 @@ import 'reflect-metadata'; +import { createHmac, timingSafeEqual } from 'node:crypto'; import { Body, Controller, @@ -20,21 +21,108 @@ import { validatePluginManifest, } from '@life-os/plugin-sdk'; +type IntegrationProblemCode = + | 'invalid_plugin_contract' + | 'invalid_gateway_context' + | 'gateway_context_unavailable'; + interface IntegrationProblemDetails { readonly type: 'about:blank'; readonly title: string; readonly status: number; - readonly code: 'invalid_plugin_contract'; + readonly code: IntegrationProblemCode; } -function invalidContract(): HttpException { +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const UNIX_SECONDS_PATTERN = /^(?:0|[1-9]\d{0,12})$/u; +const BASE64URL_SHA256_PATTERN = /^[A-Za-z0-9_-]{43}$/u; +const MINIMUM_GATEWAY_SECRET_BYTES = 32; +const MAXIMUM_CONTEXT_AGE_SECONDS = 60; +const MAXIMUM_FUTURE_SKEW_SECONDS = 5; + +function problemException( + status: number, + title: string, + code: IntegrationProblemCode, +): HttpException { const problem: IntegrationProblemDetails = { type: 'about:blank', - title: 'Plugin contract is invalid', - status: 400, - code: 'invalid_plugin_contract', + title, + status, + code, }; - return new HttpException(problem, 400); + return new HttpException(problem, status); +} + +function invalidContract(): HttpException { + return problemException(400, 'Plugin contract is invalid', 'invalid_plugin_contract'); +} + +function invalidGatewayContext(): never { + throw problemException( + 401, + 'Trusted gateway context is invalid', + 'invalid_gateway_context', + ); +} + +function unavailableGatewayContext(): never { + throw problemException( + 503, + 'Trusted gateway context is unavailable', + 'gateway_context_unavailable', + ); +} + +/** + * Verifies short-lived tenant authority created only after gateway authentication. + * The legacy browser-selectable `x-workspace-id` header is intentionally ignored. + */ +function requireTrustedWorkspaceContext( + workspaceValue: unknown, + issuedAtValue: unknown, + signatureValue: unknown, + secretValue: unknown, + nowSeconds = Math.floor(Date.now() / 1000), +): string { + if ( + typeof secretValue !== 'string' || + Buffer.byteLength(secretValue, 'utf8') < MINIMUM_GATEWAY_SECRET_BYTES + ) { + return unavailableGatewayContext(); + } + if ( + typeof workspaceValue !== 'string' || + typeof issuedAtValue !== 'string' || + typeof signatureValue !== 'string' || + !UUID_V4_PATTERN.test(workspaceValue) || + !UNIX_SECONDS_PATTERN.test(issuedAtValue) || + !BASE64URL_SHA256_PATTERN.test(signatureValue) || + !Number.isSafeInteger(nowSeconds) || + nowSeconds < 0 + ) { + return invalidGatewayContext(); + } + + const workspaceId = workspaceValue.toLowerCase(); + const issuedAtSeconds = Number(issuedAtValue); + if ( + !Number.isSafeInteger(issuedAtSeconds) || + issuedAtSeconds > nowSeconds + MAXIMUM_FUTURE_SKEW_SECONDS || + issuedAtSeconds < nowSeconds - MAXIMUM_CONTEXT_AGE_SECONDS + ) { + return invalidGatewayContext(); + } + + const expected = createHmac('sha256', secretValue) + .update(`life-os.workspace.v1\n${workspaceId}\n${issuedAtValue}`, 'utf8') + .digest(); + const actual = Buffer.from(signatureValue, 'base64url'); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return invalidGatewayContext(); + } + return workspaceId; } @Controller() @@ -65,14 +153,19 @@ export class IntegrationController { @Post('v1/events/prepare') @HttpCode(200) prepareEvent( - @Headers('x-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-workspace-id') workspaceId: string | undefined, + @Headers('x-life-os-context-issued-at') issuedAt: string | undefined, + @Headers('x-life-os-context-signature') signature: string | undefined, @Body() body: unknown, ): PreparedPluginEvent { try { - if (!workspaceId) { - throw new PluginContractError(); - } - return preparePluginEvent(workspaceId, body); + const trustedWorkspaceId = requireTrustedWorkspaceContext( + workspaceId, + issuedAt, + signature, + process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET, + ); + return preparePluginEvent(trustedWorkspaceId, body); } catch (error) { if (error instanceof PluginContractError) { throw invalidContract(); diff --git a/apps/integration-service/src/plugin-contract.integration.test.ts b/apps/integration-service/src/plugin-contract.integration.test.ts index a5b5d24d..98f14e69 100644 --- a/apps/integration-service/src/plugin-contract.integration.test.ts +++ b/apps/integration-service/src/plugin-contract.integration.test.ts @@ -1,3 +1,4 @@ +import { createHmac } from 'node:crypto'; import type { AddressInfo } from 'node:net'; import { NestFactory } from '@nestjs/core'; import { describe, expect, it } from 'vitest'; @@ -9,19 +10,46 @@ const EVENT_ID = '59b7f370-b733-435d-a72a-40878d6cffd1'; const SUBJECT_ID = 'e021b411-f75e-4490-97a4-f1f6ee811849'; const SYNTHETIC_CSRF_TOKEN = ['unit', 'csrf', 'value'].join(':'); const TEST_EMBEDDED_VALUE = ['must', 'not', 'be', 'embedded'].join(':'); +const GATEWAY_SECRET = [ + 'integration', + 'gateway', + 'context', + 'fixture', + 'material', +].join('-'); + +function signedWorkspaceHeaders( + workspaceId: string, + issuedAt = String(Math.floor(Date.now() / 1000)), +): Record { + const normalizedWorkspaceId = workspaceId.toLowerCase(); + const signature = createHmac('sha256', GATEWAY_SECRET) + .update( + `life-os.workspace.v1\n${normalizedWorkspaceId}\n${issuedAt}`, + 'utf8', + ) + .digest('base64url'); + return { + 'x-life-os-workspace-id': workspaceId, + 'x-life-os-context-issued-at': issuedAt, + 'x-life-os-context-signature': signature, + }; +} async function postJson( port: number, path: string, body: unknown, workspaceId?: string, + headers: Record = {}, ): Promise { return await fetch(`http://127.0.0.1:${port}${path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-csrf-token': SYNTHETIC_CSRF_TOKEN, - ...(workspaceId ? { 'x-workspace-id': workspaceId } : {}), + ...(workspaceId ? signedWorkspaceHeaders(workspaceId) : {}), + ...headers, }, body: JSON.stringify(body), }); @@ -39,7 +67,9 @@ function eventRequest(data: unknown = { title: 'Prepare launch', version: 2 }) { } describe('plugin contract HTTP boundary', () => { - it('exposes strict discovery, manifest validation, and tenant-scoped event preparation only', async () => { + it('exposes strict discovery, manifest validation, and signed tenant-scoped event preparation only', async () => { + const previousSecret = process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET; + process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET; const app = await NestFactory.create(IntegrationAppModule, { logger: false }); await app.listen(0, '127.0.0.1'); @@ -148,7 +178,38 @@ describe('plugin contract HTTP boundary', () => { '/v1/events/prepare', eventRequest(), ); - expect(noWorkspace.status).toBe(400); + expect(noWorkspace.status).toBe(401); + expect(await noWorkspace.json()).toMatchObject({ + code: 'invalid_gateway_context', + }); + + const forged = await postJson( + address.port, + '/v1/events/prepare', + eventRequest(), + undefined, + { + ...signedWorkspaceHeaders(WORKSPACE_ID), + 'x-life-os-context-signature': 'A'.repeat(43), + }, + ); + expect(forged.status).toBe(401); + expect(await forged.json()).toMatchObject({ + code: 'invalid_gateway_context', + }); + + delete process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET; + const unverifiable = await postJson( + address.port, + '/v1/events/prepare', + eventRequest(), + WORKSPACE_ID, + ); + expect(unverifiable.status).toBe(503); + expect(await unverifiable.json()).toMatchObject({ + code: 'gateway_context_unavailable', + }); + process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET = GATEWAY_SECRET; const unsupportedDelivery = await postJson( address.port, @@ -166,6 +227,11 @@ describe('plugin contract HTTP boundary', () => { expect(unsupportedCommand.status).toBe(404); } finally { await app.close(); + if (previousSecret === undefined) { + delete process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET; + } else { + process.env.INTEGRATION_GATEWAY_CONTEXT_SECRET = previousSecret; + } } }); });