Skip to content

Commit a98cbba

Browse files
authored
fix(lightspeed): [RHIDP-11659] Encrypt MCP user tokens at rest, fix Bearer prefix for MCP Server validation (#2671)
* fix(lightspeed): Encrypt MCP user tokens at rest, fix Bearer prefix for direct MCP server validation Assisted-by: Claude Opus 4.6 Generated-by: Cursor Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com> * fix(lightspeed): Handle plaintext token to encryption migration & handle key rotations Assisted-by: Claude Opus 4.6 Generated-by: Cursor Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com> --------- Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
1 parent c45aa19 commit a98cbba

8 files changed

Lines changed: 550 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-lightspeed-backend': patch
3+
---
4+
5+
Encrypt MCP user tokens at rest using AES-256-GCM when backend.auth.keys is configured, fix Bearer prefix for direct MCP server validation

workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const MOCK_TOOLS = [
2828
export const mcpHandlers: HttpHandler[] = [
2929
http.post(MOCK_MCP_ADDR, async ({ request }) => {
3030
const auth = request.headers.get('Authorization');
31-
if (auth !== `${MOCK_MCP_VALID_TOKEN}`) {
31+
if (auth !== `Bearer ${MOCK_MCP_VALID_TOKEN}`) {
3232
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
3333
}
3434

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
* limitations under the License.
1515
*/
1616

17+
import type { LoggerService } from '@backstage/backend-plugin-api';
18+
1719
import { Knex } from 'knex';
1820

1921
import { randomUUID } from 'node:crypto';
2022

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

2326
const TABLE = 'lightspeed_mcp_user_settings';
2427

@@ -28,25 +31,79 @@ const TABLE = 'lightspeed_mcp_user_settings';
2831
* Each row represents one user's settings for one static MCP server:
2932
* enabled/disabled toggle, optional personal token override, and
3033
* cached validation status.
34+
*
35+
* Tokens are encrypted/decrypted transparently via the TokenEncryptor.
3136
*/
3237
export class McpUserSettingsStore {
33-
constructor(private readonly db: Knex) {}
38+
constructor(
39+
private readonly db: Knex,
40+
private readonly encryptor: TokenEncryptor,
41+
private readonly logger: LoggerService,
42+
) {}
3443

3544
/** List all settings for a specific user. */
3645
async listByUser(userEntityRef: string): Promise<McpUserSettingsRow[]> {
37-
return this.db<McpUserSettingsRow>(TABLE)
46+
const rows = await this.db<McpUserSettingsRow>(TABLE)
3847
.where({ user_entity_ref: userEntityRef })
3948
.select('*');
49+
return Promise.all(rows.map(r => this.decryptRow(r)));
4050
}
4151

4252
/** Get settings for a specific server + user combination. */
4353
async get(
4454
serverName: string,
4555
userEntityRef: string,
4656
): Promise<McpUserSettingsRow | undefined> {
47-
return this.db<McpUserSettingsRow>(TABLE)
57+
const row = await this.db<McpUserSettingsRow>(TABLE)
4858
.where({ server_name: serverName, user_entity_ref: userEntityRef })
4959
.first();
60+
return row ? this.decryptRow(row) : undefined;
61+
}
62+
63+
private async decryptRow(
64+
row: McpUserSettingsRow,
65+
): Promise<McpUserSettingsRow> {
66+
if (!row.token) {
67+
return row;
68+
}
69+
try {
70+
const result = this.encryptor.decrypt(row.token);
71+
if (result.needsReEncrypt && result.plaintext) {
72+
await this.reEncryptToken(
73+
row.server_name,
74+
row.user_entity_ref,
75+
result.plaintext,
76+
);
77+
}
78+
return { ...row, token: result.plaintext };
79+
} catch (err) {
80+
this.logger.error(
81+
`Failed to decrypt token for ${row.server_name}/${row.user_entity_ref} — treating as missing`,
82+
err instanceof Error ? err : undefined,
83+
);
84+
return { ...row, token: null };
85+
}
86+
}
87+
88+
private async reEncryptToken(
89+
serverName: string,
90+
userEntityRef: string,
91+
plaintext: string,
92+
): Promise<void> {
93+
try {
94+
const encrypted = this.encryptor.encrypt(plaintext);
95+
await this.db(TABLE)
96+
.where({ server_name: serverName, user_entity_ref: userEntityRef })
97+
.update({ token: encrypted, updated_at: new Date().toISOString() });
98+
this.logger.info(
99+
`Re-encrypted token for ${serverName} with the current primary key`,
100+
);
101+
} catch (err) {
102+
this.logger.warn(
103+
`Failed to re-encrypt token for ${serverName} — will retry on next read`,
104+
err instanceof Error ? err : undefined,
105+
);
106+
}
50107
}
51108

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

60120
const row: McpUserSettingsRow = {
61121
id: randomUUID(),
62122
server_name: serverName,
63123
user_entity_ref: userEntityRef,
64124
enabled: updates.enabled ?? true,
65-
token: updates.token ?? null,
125+
token: encryptedToken,
66126
status: 'unknown',
67127
tool_count: 0,
68128
created_at: now,
@@ -72,8 +132,7 @@ export class McpUserSettingsStore {
72132
const mergeFields: Partial<McpUserSettingsRow> = { updated_at: now };
73133
if (updates.enabled !== undefined) mergeFields.enabled = updates.enabled;
74134
if (updates.token !== undefined) {
75-
mergeFields.token = updates.token;
76-
// Reset cached validation when token changes (new or cleared)
135+
mergeFields.token = encryptedToken;
77136
mergeFields.status = 'unknown';
78137
mergeFields.tool_count = 0;
79138
}

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ export class McpServerValidator {
3232
constructor(private readonly logger: LoggerService) {}
3333

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

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from '@backstage/backend-test-utils';
2323
import { AuthorizeResult } from '@backstage/plugin-permission-common';
2424

25+
import { http, HttpResponse } from 'msw';
2526
import { setupServer } from 'msw/node';
2627
import request from 'supertest';
2728

@@ -77,6 +78,26 @@ const MCP_CONFIG_MULTI = {
7778
},
7879
};
7980

81+
const MCP_CONFIG_ENCRYPTED = {
82+
backend: {
83+
auth: {
84+
keys: [{ secret: 'EXAMPLE-key-EXAMPLE-key-EXAMPLE!' }], // notsecret
85+
},
86+
},
87+
lightspeed: {
88+
...BASE_CONFIG.lightspeed,
89+
mcpServers: [
90+
{
91+
name: 'static-mcp',
92+
token: MOCK_MCP_VALID_TOKEN,
93+
},
94+
{
95+
name: 'no-token-server',
96+
},
97+
],
98+
},
99+
};
100+
80101
jest.mock('@backstage/backend-plugin-api', () => ({
81102
...jest.requireActual('@backstage/backend-plugin-api'),
82103
UserInfoService: jest.fn().mockImplementation(() => ({
@@ -379,6 +400,31 @@ describe('MCP server management endpoints', () => {
379400
expect(response.body.error).toContain('url and token are required');
380401
});
381402

403+
it('sends Bearer prefix when validating directly against MCP server', async () => {
404+
let capturedAuth = '';
405+
server.use(
406+
http.post(MOCK_MCP_ADDR, ({ request: req }) => {
407+
capturedAuth = req.headers.get('Authorization') || '';
408+
return HttpResponse.json({
409+
jsonrpc: '2.0',
410+
result: {
411+
protocolVersion: '2024-11-05',
412+
capabilities: { tools: {} },
413+
serverInfo: { name: 'mock', version: '1.0.0' },
414+
},
415+
id: 1,
416+
});
417+
}),
418+
);
419+
420+
const backendServer = await startBackendServer(MCP_CONFIG);
421+
await request(backendServer)
422+
.post('/api/lightspeed/mcp-servers/validate')
423+
.send({ url: MOCK_MCP_ADDR, token: 'my-raw-token' });
424+
425+
expect(capturedAuth).toBe('Bearer my-raw-token');
426+
});
427+
382428
it('rejects unknown URL (SSRF protection)', async () => {
383429
const backendServer = await startBackendServer(MCP_CONFIG);
384430
const response = await request(backendServer)
@@ -471,4 +517,53 @@ describe('MCP server management endpoints', () => {
471517
expect(response.status).toBe(403);
472518
});
473519
});
520+
521+
// ─── Token encryption integration ─────────────────────────────────
522+
523+
describe('Token encryption (backend.auth.keys configured)', () => {
524+
it('stores encrypted token and still validates correctly', async () => {
525+
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);
526+
527+
const patchRes = await request(backendServer)
528+
.patch('/api/lightspeed/mcp-servers/static-mcp')
529+
.send({ token: MOCK_MCP_VALID_TOKEN });
530+
531+
expect(patchRes.status).toBe(200);
532+
expect(patchRes.body.server.hasUserToken).toBe(true);
533+
expect(patchRes.body.server.status).toBe('connected');
534+
expect(patchRes.body.validation.valid).toBe(true);
535+
});
536+
537+
it('decrypted token is used for validation, not ciphertext', async () => {
538+
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);
539+
540+
await request(backendServer)
541+
.patch('/api/lightspeed/mcp-servers/no-token-server')
542+
.send({ token: MOCK_MCP_VALID_TOKEN });
543+
544+
const validateRes = await request(backendServer).post(
545+
'/api/lightspeed/mcp-servers/no-token-server/validate',
546+
);
547+
548+
expect(validateRes.status).toBe(200);
549+
expect(validateRes.body.status).toBe('connected');
550+
expect(validateRes.body.toolCount).toBe(3);
551+
});
552+
553+
it('clearing token works with encryption enabled', async () => {
554+
const backendServer = await startBackendServer(MCP_CONFIG_ENCRYPTED);
555+
556+
await request(backendServer)
557+
.patch('/api/lightspeed/mcp-servers/static-mcp')
558+
.send({ token: MOCK_MCP_VALID_TOKEN });
559+
560+
const clearRes = await request(backendServer)
561+
.patch('/api/lightspeed/mcp-servers/static-mcp')
562+
.send({ token: null });
563+
564+
expect(clearRes.status).toBe(200);
565+
expect(clearRes.body.server.hasUserToken).toBe(false);
566+
expect(clearRes.body.server.status).toBe('unknown');
567+
});
568+
});
474569
});

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
} from './mcp-server-types';
4242
import { McpServerValidator } from './mcp-server-validator';
4343
import { userPermissionAuthorization } from './permission';
44+
import { createTokenEncryptor } from './token-encryption';
4445
import {
4546
DEFAULT_HISTORY_LENGTH,
4647
QueryRequestBody,
@@ -124,7 +125,8 @@ export async function createRouter(
124125

125126
// Initialize database-backed store for per-user preferences and validator
126127
const dbClient = await database.getClient();
127-
const settingsStore = new McpUserSettingsStore(dbClient);
128+
const encryptor = createTokenEncryptor(config, logger);
129+
const settingsStore = new McpUserSettingsStore(dbClient, encryptor, logger);
128130
const mcpValidator = new McpServerValidator(logger);
129131

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

191+
const hasAllUrls = staticServers.every(s => lcsUrlCache.has(s.name));
192+
if (!hasAllUrls) {
193+
await refreshLcsUrlCache();
194+
}
195+
189196
const servers: McpServerResponse[] = staticServers.map(server => {
190197
const setting = settingsMap.get(server.name);
191198
return {

0 commit comments

Comments
 (0)