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
+42 -1
View File
@@ -109,6 +109,8 @@ import {
buildRunTurnToolingConfig as buildSessionToolingConfig,
validateSessionToolingSelectionIds,
} from '@main/sessionToolingConfig';
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow } from '@main/services/mcpOAuthService';
const { dialog, shell } = electron;
@@ -889,6 +891,42 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async startSessionMcpAuth(sessionId: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
if (!session.pendingMcpAuth) {
return workspace;
}
session.pendingMcpAuth.status = 'authenticating';
session.updatedAt = nowIso();
await this.persistAndBroadcast(workspace);
const result = await performMcpOAuthFlow({
serverUrl: session.pendingMcpAuth.serverUrl,
staticClientConfig: session.pendingMcpAuth.staticClientConfig,
});
const workspaceAfter = await this.loadWorkspace();
const sessionAfter = this.requireSession(workspaceAfter, sessionId);
if (!sessionAfter.pendingMcpAuth) {
return workspaceAfter;
}
if (result.success) {
sessionAfter.pendingMcpAuth.status = 'authenticated';
sessionAfter.pendingMcpAuth.completedAt = nowIso();
} else {
sessionAfter.pendingMcpAuth.status = 'failed';
sessionAfter.pendingMcpAuth.errorMessage = result.error ?? 'Authentication failed';
}
sessionAfter.updatedAt = nowIso();
return this.persistAndBroadcast(workspaceAfter);
}
async updateSessionTooling(
sessionId: string,
enabledMcpServerIds: string[],
@@ -1600,7 +1638,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const tooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling);
const selection = resolveSessionToolingSelection(session);
validateSessionToolingSelectionIds(tooling, selection);
return buildSessionToolingConfig(tooling, selection);
return buildSessionToolingConfig(tooling, selection, (serverUrl) => {
const token = getStoredToken(serverUrl);
return token?.accessToken;
});
}
private async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {