feat: implement MCP OAuth 2.1 flow, token storage, and injection

- Create mcpTokenStore: in-memory token storage keyed by normalized server URL
  with automatic expiry checking
- Create mcpOAuthService: full OAuth 2.1 + PKCE flow implementation
  - Protected Resource Metadata discovery (RFC 9728)
  - Authorization Server Metadata fetch (RFC 8414)
  - Dynamic Client Registration (RFC 7591) when no static client ID
  - PKCE S256 code challenge generation
  - Local HTTP callback server for auth code receipt
  - Browser-based consent via Electron shell.openExternal
  - Authorization code to token exchange
- Add startSessionMcpAuth IPC channel and handler to trigger OAuth flow
- Inject stored OAuth tokens as Authorization headers in buildRunTurnToolingConfig
- Update McpAuthBanner with 'Authenticate in browser' button and loading state
- Add tests for token store (7 tests) and token injection (3 tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-27 19:15:37 +01:00
co-authored by Copilot
parent f9757d5ce2
commit ac48aa58e0
13 changed files with 635 additions and 8 deletions
+55
View File
@@ -117,4 +117,59 @@ describe('session tooling config helpers', () => {
}),
).toBeUndefined();
});
test('injects OAuth token as Authorization header for remote MCP servers', () => {
const tokenLookup = (url: string) =>
url === 'https://example.com/mcp' ? 'oauth-access-token' : undefined;
const config = buildRunTurnToolingConfig(
TOOLING,
{ enabledMcpServerIds: ['mcp-remote'], enabledLspProfileIds: [] },
tokenLookup,
);
expect(config?.mcpServers[0]).toMatchObject({
id: 'mcp-remote',
headers: { Authorization: 'Bearer oauth-access-token' },
});
});
test('preserves existing headers when injecting OAuth token', () => {
const toolingWithHeaders: WorkspaceToolingSettings = {
...TOOLING,
mcpServers: [
{
id: 'mcp-custom',
name: 'Custom MCP',
transport: 'http',
url: 'https://custom.example.com/mcp',
headers: { 'X-Custom': 'value' },
tools: [],
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
},
],
};
const config = buildRunTurnToolingConfig(
toolingWithHeaders,
{ enabledMcpServerIds: ['mcp-custom'], enabledLspProfileIds: [] },
() => 'my-token',
);
expect(config?.mcpServers[0].headers).toEqual({
'X-Custom': 'value',
Authorization: 'Bearer my-token',
});
});
test('does not inject Authorization header when no token is available', () => {
const config = buildRunTurnToolingConfig(
TOOLING,
{ enabledMcpServerIds: ['mcp-remote'], enabledLspProfileIds: [] },
() => undefined,
);
expect(config?.mcpServers[0].headers).toEqual({ Authorization: 'Bearer token' });
});
});