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
266 changes: 257 additions & 9 deletions backend/__tests__/service/hostedMcpDcrRegistry.test.js

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions backend/__tests__/unit/services/connectionRemoval.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ describe('removeConnection (a hosted-MCP row)', () => {
expect(deps.clientIdFor).not.toHaveBeenCalled();
});

it('revokes a DCR confidential row with the method it registered (TASK-248, Vera #2377)', async () => {
const dcrEntry = { ...ENTRY, client: 'dcr' };
const { deps, revokeGrants } = recorder({ entryFor: () => dcrEntry });
deps.dcrClientForRow = jest.fn(async () => ({
clientId: 'conf-1', clientSecret: 'registered-secret', tokenEndpointAuthMethod: 'client_secret_post',
}));
const row = hostedRow({ config: {
entryId: 'linear', clientId: 'conf-1', credentialRef: 'access-ref', refreshTokenRef: 'refresh-ref',
} });
const result = await remove(row, deps, revokeGrants);
expect(result.removed).toBe(true);
expect(deps.revokeAtVendor).toHaveBeenCalledWith(expect.objectContaining({
clientId: 'conf-1', clientSecret: 'registered-secret', tokenEndpointAuthMethod: 'client_secret_post',
}));
});

it('removes a refused DCR row locally and returns the vendor page without sending its token', async () => {
const dcrEntry = { ...ENTRY, client: 'dcr' };
const { deps, revokeGrants } = recorder({ entryFor: () => dcrEntry });
Expand Down Expand Up @@ -542,6 +558,25 @@ describe('revokeTokenAtVendor (RFC 7009)', () => {
refuse.mockRestore();
}
});
it('a post-registered DCR client revokes with post, though a dcr removal reads no metadata', async () => {
const dcrEntry = { ...ENTRY, client: 'dcr' };
const fetchImpl = jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) }));
await revokeTokenAtVendor({
entry: dcrEntry,
clientId: 'conf-1',
clientSecret: 'registered-secret',
tokenEndpointAuthMethod: 'client_secret_post',
token: 'held-token',
tokenTypeHint: 'refresh_token',
}, fetchImpl);
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [, init] = fetchImpl.mock.calls[0];
expect(init.headers.Authorization).toBeUndefined();
const body = new URLSearchParams(init.body);
expect(body.get('client_id')).toBe('conf-1');
expect(body.get('client_secret')).toBe('registered-secret');
});

const call = (response) => {
const fetchImpl = jest.fn(async () => response);
return {
Expand Down
44 changes: 44 additions & 0 deletions backend/__tests__/unit/services/hostedMcpCredential.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const {
EXPIRY_SKEW_MS,
REFRESH_LEASE_MS,
WINNER_WAIT_MS,
refreshTokenAtVendor,
} = require('../../../services/hostedMcpCredentialService');
const { undeclaredPaths } = require('../../utils/schemaPathGuard');

Expand Down Expand Up @@ -222,6 +223,49 @@ describe('the fast path', () => {
expect(h.deps.clientIdFor).not.toHaveBeenCalled();
});

test('a DCR confidential client refreshes with the method it registered (TASK-248, Vera #2377)', async () => {
const dcrEntry = { ...ENTRY, client: 'dcr' };
const row = stale({ clientId: 'conf-1' });
const h = harness({ row, entryFor: () => dcrEntry });
h.deps.dcrClientForRow = jest.fn(async () => ({
clientId: 'conf-1', clientSecret: 'registered-secret', tokenEndpointAuthMethod: 'client_secret_post',
}));
await credentialFor(row, h.deps);
expect(h.refreshAtVendor).toHaveBeenCalledWith(expect.objectContaining({
clientId: 'conf-1', clientSecret: 'registered-secret', tokenEndpointAuthMethod: 'client_secret_post',
}));
});

test('the vendor refresh sends a DCR client\'s recorded method, whatever the vendor lists', async () => {
// Discovery lists basic first, so a list lookup would pick Basic; the
// registration said post, and post is what must go out (Vera, #2377).
const dcrEntry = { ...ENTRY, client: 'dcr' };
const metadata = {
issuer: dcrEntry.issuer,
authorization_endpoint: `${dcrEntry.issuer}/authorize`,
token_endpoint: `${dcrEntry.issuer}/token`,
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
};
const fetchImpl = jest.fn()
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => metadata })
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ access_token: 'new-access', expires_in: 3600 }) });
const result = await refreshTokenAtVendor({
entry: dcrEntry,
clientId: 'conf-1',
clientSecret: 'registered-secret',
tokenEndpointAuthMethod: 'client_secret_post',
refreshToken: 'old-refresh',
}, fetchImpl);
expect(result.accessToken).toBe('new-access');
const [url, init] = fetchImpl.mock.calls[1];
expect(url).toBe(metadata.token_endpoint);
expect(init.headers.Authorization).toBeUndefined();
const body = new URLSearchParams(init.body);
expect(body.get('client_id')).toBe('conf-1');
expect(body.get('client_secret')).toBe('registered-secret');
expect(body.get('refresh_token')).toBe('old-refresh');
});

test('a refused or unknown DCR client requires reconnect before serving any token', async () => {
const dcrEntry = { ...ENTRY, client: 'dcr' };
const row = live({ clientId: 'refused-client' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ const {
const { sweep } = require('../../../services/installable/installableReconciler');
const { TELEGRAM_CONNECTOR, SLACK_CONNECTOR } = require('../../../scripts/seed-builtin-connectors');
const { put } = require('../../../services/connectorSecrets');
const { DISCORD_WEBHOOK_URL, SLACK_BOT_TOKEN } = require('../../../services/connectorSecretKinds');
const {
DISCORD_WEBHOOK_URL, HOSTED_MCP_DCR_CLIENT_SECRET, SLACK_BOT_TOKEN,
} = require('../../../services/connectorSecretKinds');
const HostedMcpDcrRegistration = require('../../../models/HostedMcpDcrRegistration');
const telegramService = require('../../../services/telegramService');
const { relayTelegramMessageToPod } = require('../../../services/telegramBridgeService');
const {
Expand Down Expand Up @@ -944,6 +947,57 @@ describe('installable connector projection', () => {
expect(integration.errorMessageUserFacing).toBe(true);
});

// TASK-248: a confidential hosted-MCP client's secret is owned by its
// registration, not by an Integration row. Read only through the Integration
// paths, the sweep would delete it ten minutes after registration, and every
// connection that client minted would fail its next refresh.
const seedDcrRegistrationWithSecret = async (overrides = {}) => {
const registrationId = new mongoose.Types.ObjectId();
const ref = await put(String(registrationId), HOSTED_MCP_DCR_CLIENT_SECRET, 'client-secret');
await HostedMcpDcrRegistration.create({
_id: registrationId,
entryId: 'gitlab',
issuer: 'https://gitlab.com',
clientId: `client-${registrationId}`,
redirectUri: 'https://api.example.test/callback',
tokenEndpointAuthMethod: 'client_secret_basic',
clientSecretRef: ref,
current: true,
...overrides,
});
return { registrationId, ref };
};

it('keeps a hosted-MCP client secret past the grace window while its registration names it, retired or not', async () => {
const current = await seedDcrRegistrationWithSecret();
const retired = await seedDcrRegistrationWithSecret({ current: false, retiredAt: new Date() });
await backdateSecret(current.ref);
const now = await backdateSecret(retired.ref);

const swept = await sweep(now);

expect(swept.orphanedSecrets).toBe(0);
expect(await ConnectorSecret.findById(current.ref)).not.toBeNull();
expect(await ConnectorSecret.findById(retired.ref)).not.toBeNull();
});

it('revokes a hosted-MCP client secret once its registration is refused, gone, or names another', async () => {
const refused = await seedDcrRegistrationWithSecret({ current: false, refusedAt: new Date() });
const orphan = await put(new mongoose.Types.ObjectId().toString(), HOSTED_MCP_DCR_CLIENT_SECRET, 'lost');
const repointed = await seedDcrRegistrationWithSecret({ current: false });
await HostedMcpDcrRegistration.updateOne({ _id: repointed.registrationId }, { $set: { clientSecretRef: 'other' } });
await backdateSecret(refused.ref);
await backdateSecret(orphan);
const now = await backdateSecret(repointed.ref);

const swept = await sweep(now);

expect(swept.orphanedSecrets).toBe(3);
expect(await ConnectorSecret.findById(refused.ref)).toBeNull();
expect(await ConnectorSecret.findById(orphan)).toBeNull();
expect(await ConnectorSecret.findById(repointed.ref)).toBeNull();
});

it('keeps a secret whose kind it cannot place instead of deleting the ref it never looked for', async () => {
// Fail-closed on an unrecognized kind: this build does not know where that
// ref lives, and "the sweep found no reference" is not evidence that none
Expand Down
18 changes: 17 additions & 1 deletion backend/models/HostedMcpDcrRegistration.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import mongoose, { Document, Model, Schema } from 'mongoose';

/** One vendor-issued public OAuth client for an instance and catalogue entry. */
/**
* One vendor-issued OAuth client for an instance and catalogue entry: public
* (`token_endpoint_auth_method: 'none'`) when the vendor offers that, otherwise
* confidential, with its secret kept encrypted in the connector-secret store
* and owned by this row (TASK-248).
*/
export interface IHostedMcpDcrRegistration extends Document {
entryId: string;
issuer: string;
clientId: string;
redirectUri: string;
/** How this client authenticates at the token and revocation endpoints. */
tokenEndpointAuthMethod?: 'none' | 'client_secret_basic' | 'client_secret_post';
/** connectorSecrets ref of the client secret; set only for a confidential client. */
clientSecretRef?: string | null;
/** When the vendor said the secret lapses; null means it never does. */
clientSecretExpiresAt?: Date | null;
current: boolean;
retiredAt?: Date | null;
refusedAt?: Date | null;
Expand All @@ -18,6 +29,11 @@ const HostedMcpDcrRegistrationSchema = new Schema<IHostedMcpDcrRegistration>({
issuer: { type: String, required: true },
clientId: { type: String, required: true },
redirectUri: { type: String, required: true },
tokenEndpointAuthMethod: {
type: String, enum: ['none', 'client_secret_basic', 'client_secret_post'], default: 'none',
},
clientSecretRef: { type: String, default: null },
clientSecretExpiresAt: { type: Date, default: null },
current: { type: Boolean, required: true },
retiredAt: { type: Date, default: null },
refusedAt: { type: Date, default: null },
Expand Down
13 changes: 12 additions & 1 deletion backend/services/connectionRemovalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
discoverAuthorizationServer,
resolvedClientId,
resolvedClientSecret,
type HostedMcpClientConfiguration,
} from './hostedMcpIntakeService';
import * as connectorSecrets from './connectorSecrets';
import { upstreamFetch } from './upstreamFetch';
Expand Down Expand Up @@ -110,6 +111,8 @@ export interface RemovalDeps {
entry: HostedMcpEntry;
clientId: string;
clientSecret?: string;
/** A DCR confidential client's registered method (TASK-248); sent as recorded. */
tokenEndpointAuthMethod?: HostedMcpClientConfiguration['tokenEndpointAuthMethod'];
token: string;
tokenTypeHint: 'refresh_token' | 'access_token';
}) => Promise<void>;
Expand Down Expand Up @@ -165,6 +168,7 @@ export const revokeTokenAtVendor = async (input: {
entry: HostedMcpEntry;
clientId: string;
clientSecret?: string;
tokenEndpointAuthMethod?: HostedMcpClientConfiguration['tokenEndpointAuthMethod'];
token: string;
tokenTypeHint: 'refresh_token' | 'access_token';
}, fetchImpl: typeof fetch = hostedVendorFetch): Promise<void> => {
Expand All @@ -184,7 +188,11 @@ export const revokeTokenAtVendor = async (input: {
const request = buildRevocationRequest(
input.entry,
metadata,
{ clientId: input.clientId, ...(input.clientSecret ? { clientSecret: input.clientSecret } : {}) },
{
clientId: input.clientId,
...(input.clientSecret ? { clientSecret: input.clientSecret } : {}),
...(input.tokenEndpointAuthMethod ? { tokenEndpointAuthMethod: input.tokenEndpointAuthMethod } : {}),
},
{ token: input.token, tokenTypeHint: input.tokenTypeHint },
);
const response = await upstreamFetch(target.endpoint, {
Expand Down Expand Up @@ -320,13 +328,15 @@ export const removeConnection = async (options: {
const recordedClientId = connection?.config?.clientId;
let clientId: string | undefined;
let clientSecret: string | undefined;
let tokenEndpointAuthMethod: HostedMcpClientConfiguration['tokenEndpointAuthMethod'];
let clientMatches = true;
try {
if (entry.client === 'dcr') {
const recorded = await deps.dcrClientForRow(entry, recordedClientId);
clientMatches = Boolean(recorded);
clientId = recorded?.clientId;
clientSecret = recorded?.clientSecret;
tokenEndpointAuthMethod = recorded?.tokenEndpointAuthMethod;
} else {
clientId = deps.clientIdFor(entry);
// During the first rollout legacy rows have no snapshot. The backfill
Expand Down Expand Up @@ -389,6 +399,7 @@ export const removeConnection = async (options: {
entry,
clientId: clientId as string,
...(clientSecret ? { clientSecret } : {}),
...(tokenEndpointAuthMethod ? { tokenEndpointAuthMethod } : {}),
token,
tokenTypeHint,
});
Expand Down
35 changes: 33 additions & 2 deletions backend/services/connectorSecretKinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ export interface ConnectorSecretKindSpec {
refPaths: readonly string[];
/** The sentence written for the person reading the Connectors page. */
unavailableReason: string;
/**
* The row that holds this kind's ref. Absent means an `Integration` row, as
* for every kind before TASK-248; `refPaths` are then paths on that row. A
* hosted-MCP client registration is not an Integration, so its secret is
* owned by the registration and the sweeps look it up there.
*/
owner?: 'integration' | 'hosted-mcp-dcr-registration';
}

/** The Slack bot token: the ref lives on the row the OAuth bind writes. */
Expand Down Expand Up @@ -85,19 +92,43 @@ export const HOSTED_MCP_REFRESH_TOKEN: ConnectorSecretKindSpec = {
unavailableReason: 'Hosted MCP connection refresh token is unavailable',
};

/**
* A confidential hosted-MCP client's secret (TASK-248). When a vendor offers
* no public-client registration (gitlab.com and api.supabase.com list only
* client_secret_basic and client_secret_post), the instance registers a
* confidential client and keeps the issued secret here. It belongs to the
* registration, not to any one person's connection: every row minted by that
* client authenticates its refresh and revocation with it.
*/
export const HOSTED_MCP_DCR_CLIENT_SECRET: ConnectorSecretKindSpec = {
kind: 'hosted-mcp-dcr-client-secret',
provider: 'hosted-mcp',
refPaths: ['clientSecretRef'],
unavailableReason: 'Hosted MCP client registration secret is unavailable',
owner: 'hosted-mcp-dcr-registration',
};

export const CONNECTOR_SECRET_KINDS: readonly ConnectorSecretKindSpec[] = [
SLACK_BOT_TOKEN,
DISCORD_WEBHOOK_URL,
HOSTED_MCP_ACCESS_TOKEN,
HOSTED_MCP_REFRESH_TOKEN,
HOSTED_MCP_DCR_CLIENT_SECRET,
];

/** Whether a kind's ref lives on an Integration row (the default owner). */
export const ownedByIntegration = (spec: ConnectorSecretKindSpec): boolean => (
(spec.owner ?? 'integration') === 'integration'
);

export const kindSpec = (kind: string): ConnectorSecretKindSpec | undefined => (
CONNECTOR_SECRET_KINDS.find((spec) => spec.kind === kind)
);

/** Every ref path the sweeps must inspect, across all kinds. */
export const allRefPaths = (): string[] => CONNECTOR_SECRET_KINDS.flatMap((spec) => [...spec.refPaths]);
/** Every ref path on an Integration row the sweeps must inspect, across kinds. */
export const allRefPaths = (): string[] => CONNECTOR_SECRET_KINDS
.filter(ownedByIntegration)
.flatMap((spec) => [...spec.refPaths]);

const valueAtPath = (row: unknown, path: string): unknown => path
.split('.')
Expand Down
Loading
Loading