Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(');
});
});
115 changes: 104 additions & 11 deletions apps/integration-service/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'reflect-metadata';
import { createHmac, timingSafeEqual } from 'node:crypto';
import {
Body,
Controller,
Expand All @@ -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()
Expand Down Expand Up @@ -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();
Expand Down
72 changes: 69 additions & 3 deletions apps/integration-service/src/plugin-contract.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, string> {
const normalizedWorkspaceId = workspaceId.toLowerCase();
const signature = createHmac('sha256', GATEWAY_SECRET)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
.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<string, string> = {},
): Promise<Response> {
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),
});
Expand All @@ -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');

Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
}
});
});
Loading