mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-29 07:58:47 +02:00
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:
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||
|
||||
import {
|
||||
getStoredToken,
|
||||
storeToken,
|
||||
clearToken,
|
||||
clearAllTokens,
|
||||
type McpOAuthToken,
|
||||
} from '@main/services/mcpTokenStore';
|
||||
|
||||
describe('MCP OAuth token store', () => {
|
||||
beforeEach(() => {
|
||||
clearAllTokens();
|
||||
});
|
||||
|
||||
test('stores and retrieves tokens by server URL', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'abc123',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com/api', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com/api')).toEqual(token);
|
||||
});
|
||||
|
||||
test('normalizes trailing slashes and case in server URLs', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'xyz',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
storeToken('https://MCP.Example.com/api/', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com/api')).toEqual(token);
|
||||
expect(getStoredToken('https://mcp.example.com/api/')).toEqual(token);
|
||||
});
|
||||
|
||||
test('returns undefined for unknown server URLs', () => {
|
||||
expect(getStoredToken('https://unknown.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clears a token for a specific server', () => {
|
||||
storeToken('https://a.example.com', { accessToken: 'a', tokenType: 'Bearer' });
|
||||
storeToken('https://b.example.com', { accessToken: 'b', tokenType: 'Bearer' });
|
||||
|
||||
clearToken('https://a.example.com');
|
||||
|
||||
expect(getStoredToken('https://a.example.com')).toBeUndefined();
|
||||
expect(getStoredToken('https://b.example.com')).toBeDefined();
|
||||
});
|
||||
|
||||
test('clears all stored tokens', () => {
|
||||
storeToken('https://a.example.com', { accessToken: 'a', tokenType: 'Bearer' });
|
||||
storeToken('https://b.example.com', { accessToken: 'b', tokenType: 'Bearer' });
|
||||
|
||||
clearAllTokens();
|
||||
|
||||
expect(getStoredToken('https://a.example.com')).toBeUndefined();
|
||||
expect(getStoredToken('https://b.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for expired tokens', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'expired',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1_000,
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns valid tokens that have not expired', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'valid',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com')).toEqual(token);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user