Skip to content

fix(lightspeed): [RHIDP-11659] Encrypt MCP user tokens at rest, fix Bearer prefix for MCP Server validation#2671

Merged
ciiay merged 2 commits intoredhat-developer:mainfrom
maysunfaisal:RHIDP-11659-2
Apr 3, 2026
Merged

fix(lightspeed): [RHIDP-11659] Encrypt MCP user tokens at rest, fix Bearer prefix for MCP Server validation#2671
ciiay merged 2 commits intoredhat-developer:mainfrom
maysunfaisal:RHIDP-11659-2

Conversation

@maysunfaisal
Copy link
Copy Markdown
Contributor

@maysunfaisal maysunfaisal commented Apr 1, 2026

Hey, I just made a Pull Request!

  • Encrypt user-saved MCP server tokens at rest using AES-256-GCM when backend.auth.keys is configured. Falls back to plaintext storage if no backend.auth.keys is configured.
    • I shared a snippet of app-config below for backend.auth.keys
  • Fix Authorization: Bearer prefix when validating directly against MCP servers (distinct from LCS proxy flow which uses MCP-HEADERS).

Address all outstanding mplementation for https://redhat.atlassian.net/browse/RHIDP-11659

✔️ Checklist

  • A changeset describing the change and affected packages. (more info)
  • Added or Updated documentation
  • Tests for new functionality and regression tests for bug fixes
  • Screenshots attached (for UI changes)

Test Changes

Enable backend.auth and test out the Lightspeed backend MCP APIs, tokens stored in the DB will be encrypted.

app-config:

backend:
  # Used for enabling authentication, secret is shared by all backend plugins
  # See https://backstage.io/docs/auth/service-to-service-auth for
  # information on the format
  auth:
    keys:
      - secret: ${BACKEND_SECRET}

Update a MCP Server token:

$ curl -s -X PATCH http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server \
>   -H "Content-Type: application/json" \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" \
>   -d '{"token": "test-secret-token"}' | jq .
{
  "server": {
    "name": "test-mcp-server",
    "url": "http://localhost:8888/mcp",
    "enabled": true,
    "status": "connected",
    "toolCount": 4,
    "hasToken": true,
    "hasUserToken": true
  },
  "validation": {
    "valid": true,
    "toolCount": 4,
    "tools": [
      {
        "name": "echo",
        "description": "Echo back a message - useful for testing connectivity."
      },
      {
        "name": "get_current_time",
        "description": "Get the current server time."
      },
      {
        "name": "add_numbers",
        "description": "Add two numbers together."
      },
      {
        "name": "get_server_info",
        "description": "Get information about this MCP server."
      }
    ]
  }
}

Verify DB (local sqlite3):

$ sqlite3 packages/backend/sqlite-data/lightspeed.sqlite \
>   "SELECT server_name, token FROM lightspeed_mcp_user_settings;"
test-mcp-server|enc:dGtuWqYPAmsNTWE4e1bhqpD2RcIZmPj7pepCdgJjGmGWv+OJFIwIU/cYaaa2

If backend.auth.keys is absent, token will be stored as plaintext:

$ sqlite3 packages/backend/sqlite-data/lightspeed.sqlite \
>   "SELECT server_name, token FROM lightspeed_mcp_user_settings;"
test-mcp-server|test-secret-token

Verify DB (local postgres):

$ podman exec lightspeed-postgres psql -U postgres -d backstage_plugin_lightspeed \
>   -c "SELECT server_name, token FROM lightspeed_mcp_user_settings;"
   server_name   |                              token                               
-----------------+------------------------------------------------------------------
 test-mcp-server | enc:bRkyePWJJgnsGcYsqcoqW8l9fRyR6ww+aBsX3p8jyaLRRB5jTsibaELLEXn/

Confirming Decryption works as well when the validate API is called against a MCP Server:

mfaisal-mac:lightspeed maysun$ curl -s -X POST http://localhost:7007/api/lightspeed/mcp-servers/test-mcp-server/validate \
>   -H "Authorization: Bearer $BACKSTAGE_TOKEN" | jq .
{
  "name": "test-mcp-server",
  "status": "connected",
  "toolCount": 4,
  "validation": {
    "valid": true,
    "toolCount": 4,
    "tools": [
      {
        "name": "echo",
        "description": "Echo back a message - useful for testing connectivity."
      },
      {
        "name": "get_current_time",
        "description": "Get the current server time."
      },
      {
        "name": "add_numbers",
        "description": "Add two numbers together."
      },
      {
        "name": "get_server_info",
        "description": "Get information about this MCP server."
      }
    ]
  }
}

@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

Encrypt MCP user tokens at rest and fix Bearer prefix for direct server validation

🐞 Bug fix ✨ Enhancement

Grey Divider

Walkthroughs

Description
• Encrypt MCP user tokens at rest using AES-256-GCM when backend.auth.keys is configured
• Fix Bearer prefix in Authorization header for direct MCP server validation
• Implement transparent token encryption/decryption in McpUserSettingsStore
• Add comprehensive encryption tests and integration tests for encrypted token workflows
Diagram
flowchart LR
  A["MCP User Token"] -->|encrypt| B["TokenEncryptor"]
  B -->|AES-256-GCM| C["Encrypted Token<br/>enc:..."]
  C -->|store| D["Database"]
  D -->|retrieve| E["Encrypted Token"]
  E -->|decrypt| B
  B -->|plaintext| F["MCP Validator"]
  F -->|Bearer prefix| G["Direct MCP Server"]
Loading

Grey Divider

File Changes

1. workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts ✨ Enhancement +121/-0

New token encryption implementation with AES-256-GCM

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts


2. workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.test.ts 🧪 Tests +137/-0

Comprehensive tests for token encryption and decryption

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.test.ts


3. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts ✨ Enhancement +23/-6

Integrate token encryption into user settings store

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


View more (6)
4. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-validator.ts 🐞 Bug fix +5/-1

Add Bearer prefix to Authorization header for MCP validation

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


5. workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts ✨ Enhancement +8/-1

Initialize token encryptor and pass to settings store

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


6. workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server.test.ts 🧪 Tests +95/-0

Add encryption integration tests and Bearer prefix validation

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


7. workspaces/lightspeed/plugins/lightspeed-backend/__fixtures__/mcpHandlers.ts 🧪 Tests +1/-1

Update mock handler to expect Bearer prefix in Authorization

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


8. workspaces/lightspeed/.changeset/clever-impalas-warn.md 📝 Documentation +5/-0

Changeset documenting token encryption and Bearer prefix fixes

workspaces/lightspeed/.changeset/clever-impalas-warn.md


9. workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md Formatting +26/-26

Reorder translation keys in API report (alphabetical sort)

workspaces/lightspeed/plugins/lightspeed/report-alpha.api.md


Grey Divider

Qodo Logo

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Apr 1, 2026

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX Issues (0)

Grey Divider


Action required

1. Legacy tokens become null 🐞 Bug ≡ Correctness
Description
When backend.auth.keys is configured, AesGcmEncryptor.decrypt returns null for any stored token
without the "enc:" prefix, so existing plaintext DB tokens are treated as missing and won’t be used
for MCP-HEADERS or direct validation.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[R64-67]

+  decrypt(stored: string): string | null {
+    if (!stored.startsWith(ENCRYPTED_PREFIX)) {
+      return null;
+    }
Evidence
The encryptor explicitly returns null for non-"enc:" strings; the store overwrites row.token with
that return value; router logic then treats a null token as absent and falls back to admin/default
tokens (or no token).

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[64-67]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-63]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[81-88]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[287-293]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
With encryption enabled, previously stored plaintext tokens (no `enc:` prefix) decrypt to `null`, so user overrides stop working after enabling `backend.auth.keys`.

### Issue Context
Tokens are read via `McpUserSettingsStore.decryptRow()` and later used in `buildMcpHeaders()` / direct validation selection (`setting?.token || server.token`). Returning `null` for legacy plaintext causes silent behavior changes.

### Fix Focus Areas
- Decide on explicit behavior for legacy plaintext tokens when encryption is enabled:
 - Option A (recommended for backward compatibility): treat non-prefixed stored values as plaintext and return them (and optionally log + re-encrypt/migrate on next write).
 - Option B: if plaintext must be rejected, surface an explicit error/state to the user instead of silently dropping, and document migration.
- file paths/lines:
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[64-83]
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-65]
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[81-88]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Decrypt error breaks APIs 🐞 Bug ☼ Reliability
Description
Decrypt can throw (e.g., tampered/invalid ciphertext) and McpUserSettingsStore calls decrypt without
try/catch, so a single bad DB token can cause /mcp-servers listing and other MCP endpoints to return
500.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[R60-63]

+  private decryptRow(row: McpUserSettingsRow): McpUserSettingsRow {
+    if (row.token) {
+      return { ...row, token: this.encryptor.decrypt(row.token) };
+    }
Evidence
The decrypt implementation performs AES-GCM finalization that throws on auth/tag mismatch; the test
suite explicitly expects decrypt to throw on tampering; the store maps decryptRow across rows
without catching, so exceptions propagate to request handlers.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[68-83]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.test.ts[93-98]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[41-47]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-63]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
If `decrypt()` throws due to corrupted/tampered ciphertext, MCP endpoints can fail with 500 because decryption is performed inline while loading user settings.

### Issue Context
`McpUserSettingsStore.listByUser()` calls `decryptRow()` for every row, and `decryptRow()` directly calls `encryptor.decrypt()` with no exception handling.

### Fix Focus Areas
- Catch decryption errors and degrade gracefully (e.g., log warn/error + treat token as null for that row), so one bad row doesn’t break the whole endpoint.
- Alternatively, change `TokenEncryptor.decrypt()` contract to never throw (return `null` on any failure) and update tests accordingly.
- file paths/lines:
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-65]
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[64-83]
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.test.ts[93-98]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Key rotation unsupported 🐞 Bug ☼ Reliability
Description
createTokenEncryptor only uses backend.auth.keys[0].secret, ignoring additional configured keys, so
values encrypted under older secrets become undecryptable when the first key changes.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[R105-107]

+  const keys = config.getOptionalConfigArray('backend.auth.keys');
+  const secret = keys?.[0]?.getOptionalString('secret');
+
Evidence
The code reads the full backend.auth.keys array but immediately selects only index 0 for both
encryption and decryption, providing no mechanism to try multiple secrets for decrypt.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[105-113]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Only the first `backend.auth.keys` secret is used, so any previously encrypted tokens can become undecryptable if operators rotate keys by changing key order/value.

### Issue Context
`createTokenEncryptor()` reads `backend.auth.keys` but selects `keys?.[0]` only.

### Fix Focus Areas
- Support multiple secrets:
 - Encrypt with the first (current) key.
 - Decrypt by attempting each configured secret until one succeeds.
- Consider logging when decryption succeeds with a non-primary key (to encourage re-encryption on next write).
- file paths/lines:
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[101-113]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. MCP list blocks on refresh 🐞 Bug ➹ Performance
Description
GET /mcp-servers now awaits refreshLcsUrlCache whenever any URL is missing; if LCS is slow/down,
this endpoint can block up to the 5s fetch timeout on repeated requests, impacting UI
responsiveness.
Code

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[R191-194]

+      const hasAllUrls = staticServers.every(s => lcsUrlCache.has(s.name));
+      if (!hasAllUrls) {
+        await refreshLcsUrlCache();
+      }
Evidence
The endpoint synchronously awaits the LCS refresh when cache is incomplete, and the refresh uses a
5s timeout; repeated calls while LCS is unavailable will repeatedly incur this delay.

workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[191-195]
workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[136-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Blocking on `refreshLcsUrlCache()` in the request path can add up to 5 seconds latency to `/mcp-servers` when LCS is unavailable.

### Issue Context
`/mcp-servers` checks cache completeness and then `await refreshLcsUrlCache()`; refresh uses `AbortSignal.timeout(5000)`.

### Fix Focus Areas
- Prefer non-blocking refresh:
 - Return current cached results immediately and trigger refresh in background.
 - Or implement debounce/TTL so refresh is not awaited (or not retried) on every request.
- Consider shorter timeout for this specific endpoint or returning partial data with an explicit “urlCacheStale” flag.
- file paths/lines:
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[136-158]
 - workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[191-195]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +64 to +67
decrypt(stored: string): string | null {
if (!stored.startsWith(ENCRYPTED_PREFIX)) {
return null;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Legacy tokens become null 🐞 Bug ≡ Correctness

When backend.auth.keys is configured, AesGcmEncryptor.decrypt returns null for any stored token
without the "enc:" prefix, so existing plaintext DB tokens are treated as missing and won’t be used
for MCP-HEADERS or direct validation.
Agent Prompt
### Issue description
With encryption enabled, previously stored plaintext tokens (no `enc:` prefix) decrypt to `null`, so user overrides stop working after enabling `backend.auth.keys`.

### Issue Context
Tokens are read via `McpUserSettingsStore.decryptRow()` and later used in `buildMcpHeaders()` / direct validation selection (`setting?.token || server.token`). Returning `null` for legacy plaintext causes silent behavior changes.

### Fix Focus Areas
- Decide on explicit behavior for legacy plaintext tokens when encryption is enabled:
  - Option A (recommended for backward compatibility): treat non-prefixed stored values as plaintext and return them (and optionally log + re-encrypt/migrate on next write).
  - Option B: if plaintext must be rejected, surface an explicit error/state to the user instead of silently dropping, and document migration.
- file paths/lines:
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[64-83]
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-65]
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/router.ts[81-88]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +60 to +63
private decryptRow(row: McpUserSettingsRow): McpUserSettingsRow {
if (row.token) {
return { ...row, token: this.encryptor.decrypt(row.token) };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Decrypt error breaks apis 🐞 Bug ☼ Reliability

Decrypt can throw (e.g., tampered/invalid ciphertext) and McpUserSettingsStore calls decrypt without
try/catch, so a single bad DB token can cause /mcp-servers listing and other MCP endpoints to return
500.
Agent Prompt
### Issue description
If `decrypt()` throws due to corrupted/tampered ciphertext, MCP endpoints can fail with 500 because decryption is performed inline while loading user settings.

### Issue Context
`McpUserSettingsStore.listByUser()` calls `decryptRow()` for every row, and `decryptRow()` directly calls `encryptor.decrypt()` with no exception handling.

### Fix Focus Areas
- Catch decryption errors and degrade gracefully (e.g., log warn/error + treat token as null for that row), so one bad row doesn’t break the whole endpoint.
- Alternatively, change `TokenEncryptor.decrypt()` contract to never throw (return `null` on any failure) and update tests accordingly.
- file paths/lines:
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/mcp-server-store.ts[60-65]
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.ts[64-83]
  - workspaces/lightspeed/plugins/lightspeed-backend/src/service/token-encryption.test.ts[93-98]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@rhdh-gh-app
Copy link
Copy Markdown

rhdh-gh-app bot commented Apr 1, 2026

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/backstage-plugin-lightspeed-backend workspaces/lightspeed/plugins/lightspeed-backend patch v1.4.0

@maysunfaisal maysunfaisal changed the title fix(lightspeed): [RHIDP-11659] Encrypt MCP user tokens at rest, fix Bearer prefix f… fix(lightspeed): [RHIDP-11659] Encrypt MCP user tokens at rest, fix Bearer prefix for MCP Server validation Apr 1, 2026
Copy link
Copy Markdown
Member

@ciiay ciiay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great overall, thanks for the PR 🙌
Just have two small backend concerns before we rely on this in UI flows:

  • Migration/backward compatibility: if a user already has a saved plaintext token and encryption is later enabled, it looks like that token may become unreadable and the user appears disconnected. Could we keep a compatibility path (or migration) so existing users don’t have to re-enter credentials unexpectedly?

  • Key rotation resilience: we currently seem to use only the first backend.auth.keys entry for decrypt. Can we support decrypting with older keys too, so rotating key order doesn’t break previously saved tokens?

Both would really help preserve the “set it and forget it” UX from the user side.

…or direct MCP server validation

Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
@maysunfaisal
Copy link
Copy Markdown
Contributor Author

Looks great overall, thanks for the PR 🙌 Just have two small backend concerns before we rely on this in UI flows:

  • Migration/backward compatibility: if a user already has a saved plaintext token and encryption is later enabled, it looks like that token may become unreadable and the user appears disconnected. Could we keep a compatibility path (or migration) so existing users don’t have to re-enter credentials unexpectedly?
  • Key rotation resilience: we currently seem to use only the first backend.auth.keys entry for decrypt. Can we support decrypting with older keys too, so rotating key order doesn’t break previously saved tokens?

Both would really help preserve the “set it and forget it” UX from the user side.

@ciiay added a new commit that handles:

  • Legacy plaintext migration (backward compatability): When backend.auth.keys is enabled on a deployment that previously stored tokens as plaintext, existing tokens are read successfully and they are automatically reencrypted with the current primary key on next read. No user action needed, no data loss.
  • Key rotation: decryption now tries all keys in backend.auth.keys in order, not just keys[0]. While encryption always uses keys[0] (primary). Also handled the case where when a token is decrypted with a non-primary key i.e. an old key, it is automatically re-encrypted with the primary/new key on that same read, migration happens transparently. Admins can safely rotate keys by adding the new key at position 0, keeping the old key at position 1. All tokens migrate automatically as users access them.. Also added logs when tokens are decrypted using non-primary keys or if they are re-encrypted with a new primary key

…dle key rotations

Assisted-by: Claude Opus 4.6

Generated-by: Cursor
Signed-off-by: Maysun J Faisal <maysunaneek@gmail.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 2, 2026

Copy link
Copy Markdown
Member

@ciiay ciiay left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm

Thanks for the PR 💯

@openshift-ci openshift-ci bot added the lgtm label Apr 3, 2026
@ciiay ciiay merged commit a98cbba into redhat-developer:main Apr 3, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants