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
5 changes: 5 additions & 0 deletions workspaces/lightspeed/.changeset/clever-impalas-warn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch
---

Encrypt MCP user tokens at rest using AES-256-GCM when backend.auth.keys is configured, fix Bearer prefix for direct MCP server validation
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const MOCK_TOOLS = [
export const mcpHandlers: HttpHandler[] = [
http.post(MOCK_MCP_ADDR, async ({ request }) => {
const auth = request.headers.get('Authorization');
if (auth !== `${MOCK_MCP_VALID_TOKEN}`) {
if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) {
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@
* limitations under the License.
*/

import type { LoggerService } from '@backstage/backend-plugin-api';

import { Knex } from 'knex';

import { randomUUID } from 'node:crypto';

import { McpServerStatus, McpUserSettingsRow } from './mcp-server-types';
import { TokenEncryptor } from './token-encryption';

const TABLE = 'lightspeed_mcp_user_settings';

Expand All @@ -28,25 +31,79 @@ const TABLE = 'lightspeed_mcp_user_settings';
* Each row represents one user's settings for one static MCP server:
* enabled/disabled toggle, optional personal token override, and
* cached validation status.
*
* Tokens are encrypted/decrypted transparently via the TokenEncryptor.
*/
export class McpUserSettingsStore {
constructor(private readonly db: Knex) {}
constructor(
private readonly db: Knex,
private readonly encryptor: TokenEncryptor,
private readonly logger: LoggerService,
) {}

/** List all settings for a specific user. */
async listByUser(userEntityRef: string): Promise<McpUserSettingsRow[]> {
return this.db<McpUserSettingsRow>(TABLE)
const rows = await this.db<McpUserSettingsRow>(TABLE)
.where({ user_entity_ref: userEntityRef })
.select('*');
return Promise.all(rows.map(r => this.decryptRow(r)));
}

/** Get settings for a specific server + user combination. */
async get(
serverName: string,
userEntityRef: string,
): Promise<McpUserSettingsRow | undefined> {
return this.db<McpUserSettingsRow>(TABLE)
const row = await this.db<McpUserSettingsRow>(TABLE)
.where({ server_name: serverName, user_entity_ref: userEntityRef })
.first();
return row ? this.decryptRow(row) : undefined;
}

private async decryptRow(
row: McpUserSettingsRow,
): Promise<McpUserSettingsRow> {
if (!row.token) {
return row;
}
try {
const result = this.encryptor.decrypt(row.token);
if (result.needsReEncrypt && result.plaintext) {
await this.reEncryptToken(
row.server_name,
row.user_entity_ref,
result.plaintext,
);
}
return { ...row, token: result.plaintext };
} catch (err) {
this.logger.error(
`Failed to decrypt token for ${row.server_name}/${row.user_entity_ref} — treating as missing`,
err instanceof Error ? err : undefined,
);
return { ...row, token: null };
}
}

private async reEncryptToken(
serverName: string,
userEntityRef: string,
plaintext: string,
): Promise<void> {
try {
const encrypted = this.encryptor.encrypt(plaintext);
await this.db(TABLE)
.where({ server_name: serverName, user_entity_ref: userEntityRef })
.update({ token: encrypted, updated_at: new Date().toISOString() });
this.logger.info(
`Re-encrypted token for ${serverName} with the current primary key`,
);
} catch (err) {
this.logger.warn(
`Failed to re-encrypt token for ${serverName} — will retry on next read`,
err instanceof Error ? err : undefined,
);
}
}

/** Create or update user settings for a server (atomic). */
Expand All @@ -56,13 +113,16 @@ export class McpUserSettingsStore {
updates: { enabled?: boolean; token?: string | null },
): Promise<McpUserSettingsRow> {
const now = new Date().toISOString();
const encryptedToken = updates.token
? this.encryptor.encrypt(updates.token)
: (updates.token ?? null);

const row: McpUserSettingsRow = {
id: randomUUID(),
server_name: serverName,
user_entity_ref: userEntityRef,
enabled: updates.enabled ?? true,
token: updates.token ?? null,
token: encryptedToken,
status: 'unknown',
tool_count: 0,
created_at: now,
Expand All @@ -72,8 +132,7 @@ export class McpUserSettingsStore {
const mergeFields: Partial<McpUserSettingsRow> = { updated_at: now };
if (updates.enabled !== undefined) mergeFields.enabled = updates.enabled;
if (updates.token !== undefined) {
mergeFields.token = updates.token;
// Reset cached validation when token changes (new or cleared)
mergeFields.token = encryptedToken;
mergeFields.status = 'unknown';
mergeFields.tool_count = 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ export class McpServerValidator {
constructor(private readonly logger: LoggerService) {}

async validate(url: string, token: string): Promise<McpValidationResult> {
// Bearer prefix is required here because the validator hits the MCP server
// directly (not through LCS). LCS handles its own auth scheme via
// MCP-HEADERS (see buildMcpHeaders in router.ts), but direct MCP
// Streamable HTTP endpoints expect standard Bearer authentication.
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Authorization: `${token}`,
Authorization: `Bearer ${token}`,
Accept: 'application/json, text/event-stream',
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
} from '@backstage/backend-test-utils';
import { AuthorizeResult } from '@backstage/plugin-permission-common';

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import request from 'supertest';

Expand Down Expand Up @@ -77,6 +78,26 @@ const MCP_CONFIG_MULTI = {
},
};

const MCP_CONFIG_ENCRYPTED = {
backend: {
auth: {
keys: [{ secret: 'EXAMPLE-key-EXAMPLE-key-EXAMPLE!' }], // notsecret
},
},
lightspeed: {
...BASE_CONFIG.lightspeed,
mcpServers: [
{
name: 'static-mcp',
token: MOCK_MCP_VALID_TOKEN,
},
{
name: 'no-token-server',
},
],
},
};

jest.mock('@backstage/backend-plugin-api', () => ({
...jest.requireActual('@backstage/backend-plugin-api'),
UserInfoService: jest.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -379,6 +400,31 @@ describe('MCP server management endpoints', () => {
expect(response.body.error).toContain('url and token are required');
});

it('sends Bearer prefix when validating directly against MCP server', async () => {
let capturedAuth = '';
server.use(
http.post(MOCK_MCP_ADDR, ({ request: req }) => {
capturedAuth = req.headers.get('Authorization') || '';
return HttpResponse.json({
jsonrpc: '2.0',
result: {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'mock', version: '1.0.0' },
},
id: 1,
});
}),
);

const backendServer = await startBackendServer(MCP_CONFIG);
await request(backendServer)
.post('/api/lightspeed/mcp-servers/validate')
.send({ url: MOCK_MCP_ADDR, token: 'my-raw-token' });

expect(capturedAuth).toBe('Bearer my-raw-token');
});

it('rejects unknown URL (SSRF protection)', async () => {
const backendServer = await startBackendServer(MCP_CONFIG);
const response = await request(backendServer)
Expand Down Expand Up @@ -471,4 +517,53 @@ describe('MCP server management endpoints', () => {
expect(response.status).toBe(403);
});
});

// ─── Token encryption integration ─────────────────────────────────

describe('Token encryption (backend.auth.keys configured)', () => {
it('stores encrypted token and still validates correctly', async () => {
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);

const patchRes = await request(backendServer)
.patch('/api/lightspeed/mcp-servers/static-mcp')
.send({ token: MOCK_MCP_VALID_TOKEN });

expect(patchRes.status).toBe(200);
expect(patchRes.body.server.hasUserToken).toBe(true);
expect(patchRes.body.server.status).toBe('connected');
expect(patchRes.body.validation.valid).toBe(true);
});

it('decrypted token is used for validation, not ciphertext', async () => {
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);

await request(backendServer)
.patch('/api/lightspeed/mcp-servers/no-token-server')
.send({ token: MOCK_MCP_VALID_TOKEN });

const validateRes = await request(backendServer).post(
'/api/lightspeed/mcp-servers/no-token-server/validate',
);

expect(validateRes.status).toBe(200);
expect(validateRes.body.status).toBe('connected');
expect(validateRes.body.toolCount).toBe(3);
});

it('clearing token works with encryption enabled', async () => {
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);

await request(backendServer)
.patch('/api/lightspeed/mcp-servers/static-mcp')
.send({ token: MOCK_MCP_VALID_TOKEN });

const clearRes = await request(backendServer)
.patch('/api/lightspeed/mcp-servers/static-mcp')
.send({ token: null });

expect(clearRes.status).toBe(200);
expect(clearRes.body.server.hasUserToken).toBe(false);
expect(clearRes.body.server.status).toBe('unknown');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
} from './mcp-server-types';
import { McpServerValidator } from './mcp-server-validator';
import { userPermissionAuthorization } from './permission';
import { createTokenEncryptor } from './token-encryption';
import {
DEFAULT_HISTORY_LENGTH,
QueryRequestBody,
Expand Down Expand Up @@ -124,7 +125,8 @@ export async function createRouter(

// Initialize database-backed store for per-user preferences and validator
const dbClient = await database.getClient();
const settingsStore = new McpUserSettingsStore(dbClient);
const encryptor = createTokenEncryptor(config, logger);
const settingsStore = new McpUserSettingsStore(dbClient, encryptor, logger);
const mcpValidator = new McpServerValidator(logger);

// URL cache populated from LCS GET /v1/mcp-servers.
Expand Down Expand Up @@ -186,6 +188,11 @@ export async function createRouter(
const userSettings = await settingsStore.listByUser(user.userEntityRef);
const settingsMap = new Map(userSettings.map(s => [s.server_name, s]));

const hasAllUrls = staticServers.every(s => lcsUrlCache.has(s.name));
if (!hasAllUrls) {
await refreshLcsUrlCache();
}

const servers: McpServerResponse[] = staticServers.map(server => {
const setting = settingsMap.get(server.name);
return {
Expand Down
Loading
Loading