feat(context): verify product session scope
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Request } from "express";
|
||||
|
||||
import {
|
||||
attachTrustedAgentContext,
|
||||
getLocalAgentContext,
|
||||
ProductContextError,
|
||||
} from "../../src/context/localContext.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
const requestWithHeaders = (headers: Record<string, string>) => ({
|
||||
header: (name: string) => headers[name.toLowerCase()],
|
||||
}) as Request;
|
||||
|
||||
const authResponse = (userId: string, projectId: string) => ({
|
||||
is_superuser: false,
|
||||
keycloak_sub: `sub-${userId}`,
|
||||
network: "supply",
|
||||
permissions: ["analysis:read"],
|
||||
project_id: projectId,
|
||||
project_role: "member",
|
||||
role: "user",
|
||||
token_expires_at: "2026-09-17T00:00:00Z",
|
||||
user_id: userId,
|
||||
username: userId,
|
||||
});
|
||||
|
||||
describe("trusted product chat context", () => {
|
||||
it("uses verified product users and projects instead of spoofable local headers", async () => {
|
||||
const identities = [authResponse("product-user-1", "project-1"), authResponse("product-user-2", "project-2")];
|
||||
globalThis.fetch = (async () => Response.json(identities.shift())) as unknown as typeof fetch;
|
||||
const first = requestWithHeaders({
|
||||
authorization: "Bearer product-token-1",
|
||||
"x-project-id": "project-1",
|
||||
"x-agent-local-user": "spoofed-user",
|
||||
"x-agent-local-project": "spoofed-project",
|
||||
});
|
||||
const second = requestWithHeaders({
|
||||
authorization: "Bearer product-token-2",
|
||||
"x-project-id": "project-2",
|
||||
"x-agent-local-user": "spoofed-user",
|
||||
"x-agent-local-project": "spoofed-project",
|
||||
});
|
||||
await attachTrustedAgentContext(first);
|
||||
await attachTrustedAgentContext(second);
|
||||
|
||||
const firstContext = getLocalAgentContext(first);
|
||||
const secondContext = getLocalAgentContext(second);
|
||||
expect(firstContext.userId).toBe("product-user-1");
|
||||
expect(firstContext.projectId).toBe("project-1");
|
||||
expect(secondContext.userId).toBe("product-user-2");
|
||||
expect(secondContext.projectId).toBe("project-2");
|
||||
expect(firstContext.actorKey).not.toBe(secondContext.actorKey);
|
||||
expect(firstContext.projectKey).not.toBe(secondContext.projectKey);
|
||||
|
||||
const localSpoof = requestWithHeaders({
|
||||
"x-agent-local-user": "product-user-1",
|
||||
"x-agent-local-project": "project-1",
|
||||
});
|
||||
await attachTrustedAgentContext(localSpoof);
|
||||
const localContext = getLocalAgentContext(localSpoof);
|
||||
expect(localContext.actorKey).not.toBe(firstContext.actorKey);
|
||||
expect(localContext.projectKey).not.toBe(firstContext.projectKey);
|
||||
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-product-session-"));
|
||||
try {
|
||||
const store = new SessionMetadataStore(tempDir);
|
||||
await store.initialize();
|
||||
await store.ensure({ ...firstContext, sessionId: "private-session" });
|
||||
expect(await store.get(secondContext, "private-session")).toBeNull();
|
||||
expect((await store.get(firstContext, "private-session"))?.sessionId).toBe("private-session");
|
||||
} finally {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("fails closed when any supplied product credential cannot be verified", async () => {
|
||||
globalThis.fetch = (async () => Response.json({ detail: "expired" }, { status: 401 })) as unknown as typeof fetch;
|
||||
const request = requestWithHeaders({
|
||||
authorization: "Bearer expired-token",
|
||||
"x-project-id": "project-1",
|
||||
});
|
||||
expect(attachTrustedAgentContext(request)).rejects.toBeInstanceOf(ProductContextError);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import {
|
||||
SessionContextMismatchError,
|
||||
SessionMetadataStore,
|
||||
} from "../../src/sessions/metadataStore.js";
|
||||
|
||||
describe("SessionMetadataStore", () => {
|
||||
let tempDir: string;
|
||||
@@ -15,6 +18,37 @@ describe("SessionMetadataStore", () => {
|
||||
await store.initialize();
|
||||
});
|
||||
|
||||
it("does not return or ensure a session owned by another context", async () => {
|
||||
await store.ensure({
|
||||
actorKey: "actor-1",
|
||||
projectId: "project-1",
|
||||
projectKey: "project-key-1",
|
||||
sessionId: "shared-id",
|
||||
userId: "user-1",
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.get(
|
||||
{
|
||||
actorKey: "actor-2",
|
||||
projectId: "project-2",
|
||||
projectKey: "project-key-2",
|
||||
userId: "user-2",
|
||||
},
|
||||
"shared-id",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
store.ensure({
|
||||
actorKey: "actor-2",
|
||||
projectId: "project-2",
|
||||
projectKey: "project-key-2",
|
||||
sessionId: "shared-id",
|
||||
userId: "user-2",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SessionContextMismatchError);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user