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
+7
View File
@@ -44,6 +44,7 @@ interface ChatPaneProps {
onSetInteractionMode?: (mode: InteractionMode) => void;
onDismissPlanReview?: () => void;
onDismissMcpAuth?: () => void;
onAuthenticateMcp?: () => void;
onUpdateSessionModelConfig?: (config: {
model: string;
reasoningEffort?: ReasoningEffort;
@@ -66,6 +67,7 @@ export function ChatPane({
onSetInteractionMode,
onDismissPlanReview,
onDismissMcpAuth,
onAuthenticateMcp,
onUpdateSessionModelConfig,
onUpdateSessionTooling,
onUpdateSessionApprovalSettings,
@@ -145,6 +147,10 @@ export function ChatPane({
onDismissMcpAuth?.();
}
function handleAuthenticateMcp() {
onAuthenticateMcp?.();
}
async function handleSessionModelConfigChange(config: {
model: string;
reasoningEffort?: ReasoningEffort;
@@ -414,6 +420,7 @@ export function ChatPane({
<div className="mb-3">
<McpAuthBanner
mcpAuth={pendingMcpAuth}
onAuthenticate={handleAuthenticateMcp}
onDismiss={handleDismissMcpAuth}
/>
</div>
+31 -6
View File
@@ -1,15 +1,21 @@
import { useCallback } from 'react';
import { KeyRound, X } from 'lucide-react';
import { KeyRound, Loader2, X } from 'lucide-react';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
export function McpAuthBanner({
mcpAuth,
onAuthenticate,
onDismiss,
}: {
mcpAuth: PendingMcpAuthRecord;
onAuthenticate: () => void;
onDismiss: () => void;
}) {
const handleAuthenticate = useCallback(() => {
onAuthenticate();
}, [onAuthenticate]);
const handleDismiss = useCallback(() => {
onDismiss();
}, [onDismiss]);
@@ -51,11 +57,30 @@ export function McpAuthBanner({
<p className="mt-2 text-[12px] text-red-400">{mcpAuth.errorMessage}</p>
)}
<p className="mt-3 text-[12px] leading-relaxed text-zinc-400">
{isAuthenticating
? 'Waiting for authentication to complete in the browser…'
: 'Authentication support for HTTP MCP servers is not yet available. Configure a static access token in the MCP server headers instead.'}
</p>
<div className="mt-3 flex items-center gap-3">
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/20 px-3 py-1.5 text-[12px] font-medium text-amber-200 transition hover:bg-amber-500/30 disabled:opacity-50"
disabled={isAuthenticating}
onClick={handleAuthenticate}
type="button"
>
{isAuthenticating ? (
<>
<Loader2 className="size-3.5 animate-spin" />
Authenticating
</>
) : hasFailed ? (
'Retry authentication'
) : (
'Authenticate in browser'
)}
</button>
<span className="text-[11px] text-zinc-500">
{isAuthenticating
? 'Waiting for consent in the browser…'
: 'Opens your browser for OAuth consent. Token is stored for this session only.'}
</span>
</div>
</div>
</div>
</div>