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
137 changes: 137 additions & 0 deletions enterprise/e2e/auth-closed/playwright/mcp-session.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { test, expect } from '@playwright/test';
import { createHmac } from 'node:crypto';

// This instance is gated at the root by all three policy types at once, so
// every path is covered, including the `/self` schemas the MCP endpoint reads
// to do its own work. That is what makes it the case worth testing: a browser
// admitted by its session cookie reaches a tool, and the tool then has to
// resolve its own envelope schemas on that same caller's behalf. Carrying only
// the bearer that far would leave a caller who passed the gate refused by the
// work done behind it, for a credential the gate had already accepted.
//
// The session is minted here rather than obtained by signing in, since what is
// under test is what a session admits once held, not how one is obtained.

const SESSION_SECRET = 'a-session-signing-secret-for-the-auth-closed-sandbox';
const SESSION_LABEL = 'sourcemeta/one/session';

function sealSession(payload, secret = SESSION_SECRET) {
const issued = Math.floor(Date.now() / 1000);
const expiry = issued + 3600;
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
const prefix = `1.${issued}.${expiry}.${encoded}`;
const key = createHmac('sha256', secret).update(SESSION_LABEL).digest();
const signature = createHmac('sha256', key).update(prefix).digest('base64url');
return `${prefix}.${signature}`;
}

// Minted per test rather than once, so that a slow run or a retry cannot drift
// past the expiry and fail for a reason that has nothing to do with the subject
function session() {
return sealSession({ policy: 'keycloak', subject: 'jane' });
}

async function mcp(request, body, cookie) {
return request.post('/self/v1/mcp', {
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'mcp-protocol-version': '2025-11-25',
...(cookie ? { cookie: `sourcemeta_one_session=${cookie}` } : {})
},
data: body,
failOnStatusCode: false
});
}

test.describe('MCP under a browser session', () => {
test('a session admits a tool call, arguments and all', async ({
request
}) => {
const response = await mcp(
request,
{
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'search_schemas', arguments: { q: 'object' } }
},
session()
);

expect(response.status()).toBe(200);
const body = await response.json();
// A tool that ran. Not an authentication refusal, and not the internal
// error that resolving its own schemas on a bearer-only credential used to
// produce for a caller holding a cookie instead
expect(body.error).toBeUndefined();
expect(body.result).toBeDefined();
expect(body.result.isError).toBeFalsy();
expect(body.result.structuredContent).toBeDefined();
});

test('a session reads a resource', async ({ request }) => {
// `resources/read` resolves an artifact on the caller's behalf, which is
// the other place the credential has to reach
const listed = await mcp(
request,
{ jsonrpc: '2.0', id: 2, method: 'resources/list' },
session()
);
expect(listed.status()).toBe(200);
// A refusal arrives as a JSON-RPC error under a 200, so it is named here
// rather than surfacing later as a failure to read a field off nothing
const listing = await listed.json();
expect(listing.error).toBeUndefined();
const resources = listing.result.resources;
expect(resources.length).toBeGreaterThan(0);

const read = await mcp(
request,
{
jsonrpc: '2.0',
id: 3,
method: 'resources/read',
params: { uri: resources[0].uri }
},
session()
);
expect(read.status()).toBe(200);
const body = await read.json();
expect(body.error).toBeUndefined();
expect(body.result.contents.length).toBeGreaterThan(0);
});

test('a caller with no credential is still refused', async ({ request }) => {
// The gate is what this instance is for, so admitting a cookie must not
// have admitted everybody
const response = await mcp(request, {
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'search_schemas', arguments: { q: 'object' } }
});
expect(response.status()).toBe(401);
});

test('a session forged under another secret is refused', async ({
request
}) => {
const forged = sealSession(
{ policy: 'keycloak', subject: 'jane' },
'a-secret-nobody-here-signs-with'
);

const response = await mcp(
request,
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'search_schemas', arguments: { q: 'object' } }
},
forged
);
expect(response.status()).toBe(401);
});
});
37 changes: 37 additions & 0 deletions enterprise/e2e/auth-closed/playwright/playwright.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { defineConfig, devices } from '@playwright/test';

// See https://playwright.dev/docs/test-configuration
export default defineConfig({
testDir: '.',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'list',
outputDir: '../../../../build/test-results',
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL,
trace: 'on-first-retry',
// The identity provider's certificate chains to a sandbox-local authority
// the browser does not know, so certificate errors are tolerated here
// while the registry container verifies the chain for real
ignoreHTTPSErrors: true
},
// Chromium only: the OIDC redirect chain relies on a Chromium-specific
// host resolver rule, so the suite never runs under Firefox or WebKit
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
// Keycloak advertises itself as `keycloak:8443` (its KC_HOSTNAME), so
// the browser must resolve that container name to the mapped local
// port to follow the OIDC redirect, exactly as a developer would via
// /etc/hosts
launchOptions: {
args: ['--host-resolver-rules=MAP keycloak 127.0.0.1']
}
}
}
]
});
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,8 @@ class ActionAuthCallback_v1 : public sourcemeta::one::RouterAction {

auto mcp(const sourcemeta::core::MCPProtocolVersion,
const sourcemeta::core::JSON &id, const sourcemeta::core::JSON &,
std::string_view) -> sourcemeta::core::JSON override {
const sourcemeta::one::Credentials &)
-> sourcemeta::core::JSON override {
return sourcemeta::core::jsonrpc_make_error_method_not_found(id);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,8 @@ class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction {

auto mcp(const sourcemeta::core::MCPProtocolVersion,
const sourcemeta::core::JSON &id, const sourcemeta::core::JSON &,
std::string_view) -> sourcemeta::core::JSON override {
const sourcemeta::one::Credentials &)
-> sourcemeta::core::JSON override {
return sourcemeta::core::jsonrpc_make_error_method_not_found(id);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ class ActionAuthLogout_v1 : public sourcemeta::one::RouterAction {

auto mcp(const sourcemeta::core::MCPProtocolVersion,
const sourcemeta::core::JSON &id, const sourcemeta::core::JSON &,
std::string_view) -> sourcemeta::core::JSON override {
const sourcemeta::one::Credentials &)
-> sourcemeta::core::JSON override {
return sourcemeta::core::jsonrpc_make_error_method_not_found(id);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,11 +338,11 @@ class ActionJSONSchemaRDF_v1 : public sourcemeta::one::RouterAction {
auto mcp(const sourcemeta::core::MCPProtocolVersion version,
const sourcemeta::core::JSON &request_id,
const sourcemeta::core::JSON &arguments,
const std::string_view credential)
const sourcemeta::one::Credentials &credentials)
-> sourcemeta::core::JSON override {
auto [request_valid, request_output]{
this->schema_evaluate({.bearer = credential}, this->rpc_request_schema_,
arguments, sourcemeta::blaze::Mode::Exhaustive)};
this->schema_evaluate(credentials, this->rpc_request_schema_, arguments,
sourcemeta::blaze::Mode::Exhaustive)};
if (!request_valid) {
return sourcemeta::core::jsonrpc_make_error(
&request_id, -32602, "Params fail against the tool request schema",
Expand All @@ -351,9 +351,9 @@ class ActionJSONSchemaRDF_v1 : public sourcemeta::one::RouterAction {

const auto &schema_uri{arguments.at("schema").to_string()};
const auto schema_present{this->artifact_resolve_path(
{.bearer = credential}, schema_uri, Tree::Schemas, "schema")};
credentials, schema_uri, Tree::Schemas, "schema")};
const auto evaluation_enabled{this->artifact_resolve_path(
{.bearer = credential}, schema_uri, Tree::Schemas, "blaze-exhaustive")};
credentials, schema_uri, Tree::Schemas, "blaze-exhaustive")};
if (schema_present.outcome ==
sourcemeta::one::ArtifactResolution::Outcome::Denied ||
evaluation_enabled.outcome ==
Expand Down Expand Up @@ -412,8 +412,7 @@ class ActionJSONSchemaRDF_v1 : public sourcemeta::one::RouterAction {
auto payload{sourcemeta::core::JSON::make_object()};
payload.assign("valid", sourcemeta::core::JSON{false});
payload.assign("errors",
this->schema_evaluate({.bearer = credential}, schema_uri,
instance,
this->schema_evaluate(credentials, schema_uri, instance,
sourcemeta::blaze::Mode::Exhaustive)
.second.at("errors"));
return sourcemeta::core::mcp_make_tool_success(version, request_id,
Expand Down
Loading
Loading