Spaces:
Running
Running
add mcp support
Browse files- src/components/OAuthCallback.tsx +97 -0
- src/config/constants.ts +35 -0
- src/utils/storage.ts +108 -0
src/components/OAuthCallback.tsx
ADDED
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useEffect, useState } from "react";
|
2 |
+
import { exchangeCodeForToken } from "../services/oauth";
|
3 |
+
import { secureStorage } from "../utils/storage";
|
4 |
+
import type { MCPServerConfig } from "../types/mcp";
|
5 |
+
import { STORAGE_KEYS, DEFAULTS } from "../config/constants";
|
6 |
+
|
7 |
+
interface OAuthTokens {
|
8 |
+
access_token: string;
|
9 |
+
refresh_token?: string;
|
10 |
+
expires_in?: number;
|
11 |
+
token_type?: string;
|
12 |
+
[key: string]: string | number | undefined;
|
13 |
+
}
|
14 |
+
|
15 |
+
interface OAuthCallbackProps {
|
16 |
+
serverUrl: string;
|
17 |
+
onSuccess?: (tokens: OAuthTokens) => void;
|
18 |
+
onError?: (error: Error) => void;
|
19 |
+
}
|
20 |
+
|
21 |
+
const OAuthCallback: React.FC<OAuthCallbackProps> = ({
|
22 |
+
serverUrl,
|
23 |
+
onSuccess,
|
24 |
+
onError,
|
25 |
+
}) => {
|
26 |
+
const [status, setStatus] = useState<string>("Authorizing...");
|
27 |
+
|
28 |
+
useEffect(() => {
|
29 |
+
const params = new URLSearchParams(window.location.search);
|
30 |
+
const code = params.get("code");
|
31 |
+
// Always persist MCP server URL for robustness
|
32 |
+
localStorage.setItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL, serverUrl);
|
33 |
+
if (code) {
|
34 |
+
exchangeCodeForToken({
|
35 |
+
serverUrl,
|
36 |
+
code,
|
37 |
+
redirectUri: window.location.origin + DEFAULTS.OAUTH_REDIRECT_PATH,
|
38 |
+
})
|
39 |
+
.then(async (tokens) => {
|
40 |
+
await secureStorage.setItem(STORAGE_KEYS.OAUTH_ACCESS_TOKEN, tokens.access_token);
|
41 |
+
// Add MCP server to MCPClientService for UI
|
42 |
+
const mcpServerUrl = localStorage.getItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
|
43 |
+
if (mcpServerUrl) {
|
44 |
+
// Use persisted name and transport from initial add
|
45 |
+
const serverName =
|
46 |
+
localStorage.getItem(STORAGE_KEYS.MCP_SERVER_NAME) || mcpServerUrl;
|
47 |
+
const serverTransport =
|
48 |
+
(localStorage.getItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT) as MCPServerConfig['transport']) || DEFAULTS.MCP_TRANSPORT;
|
49 |
+
// Build config and add to mcp-servers
|
50 |
+
const serverConfig = {
|
51 |
+
id: `server_${Date.now()}`,
|
52 |
+
name: serverName,
|
53 |
+
url: mcpServerUrl,
|
54 |
+
enabled: true,
|
55 |
+
transport: serverTransport,
|
56 |
+
auth: {
|
57 |
+
type: "bearer" as const,
|
58 |
+
token: tokens.access_token,
|
59 |
+
},
|
60 |
+
};
|
61 |
+
// Load existing servers
|
62 |
+
let servers: MCPServerConfig[] = [];
|
63 |
+
try {
|
64 |
+
const stored = localStorage.getItem(STORAGE_KEYS.MCP_SERVERS);
|
65 |
+
if (stored) servers = JSON.parse(stored);
|
66 |
+
} catch {}
|
67 |
+
// Add or update
|
68 |
+
const exists = servers.some((s: MCPServerConfig) => s.url === mcpServerUrl);
|
69 |
+
if (!exists) {
|
70 |
+
servers.push(serverConfig);
|
71 |
+
localStorage.setItem(STORAGE_KEYS.MCP_SERVERS, JSON.stringify(servers));
|
72 |
+
}
|
73 |
+
// Clear temp values from localStorage for clean slate
|
74 |
+
localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_NAME);
|
75 |
+
localStorage.removeItem(STORAGE_KEYS.MCP_SERVER_TRANSPORT);
|
76 |
+
localStorage.removeItem(STORAGE_KEYS.OAUTH_MCP_SERVER_URL);
|
77 |
+
}
|
78 |
+
setStatus("Authorization successful! Redirecting...");
|
79 |
+
if (onSuccess) onSuccess(tokens);
|
80 |
+
// Redirect to main app page after short delay
|
81 |
+
setTimeout(() => {
|
82 |
+
window.location.replace("/");
|
83 |
+
}, 1000);
|
84 |
+
})
|
85 |
+
.catch((err) => {
|
86 |
+
setStatus("OAuth token exchange failed: " + err.message);
|
87 |
+
if (onError) onError(err);
|
88 |
+
});
|
89 |
+
} else {
|
90 |
+
setStatus("Missing authorization code in callback URL.");
|
91 |
+
}
|
92 |
+
}, [serverUrl, onSuccess, onError]);
|
93 |
+
|
94 |
+
return <div>{status}</div>;
|
95 |
+
};
|
96 |
+
|
97 |
+
export default OAuthCallback;
|
src/config/constants.ts
ADDED
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Application configuration constants
|
3 |
+
*/
|
4 |
+
|
5 |
+
// MCP Client Configuration
|
6 |
+
export const MCP_CLIENT_CONFIG = {
|
7 |
+
NAME: "LFM2-WebGPU",
|
8 |
+
VERSION: "1.0.0",
|
9 |
+
TEST_CLIENT_NAME: "LFM2-WebGPU-Test",
|
10 |
+
} as const;
|
11 |
+
|
12 |
+
// Storage Keys
|
13 |
+
export const STORAGE_KEYS = {
|
14 |
+
MCP_SERVERS: "mcp-servers",
|
15 |
+
OAUTH_CLIENT_ID: "oauth_client_id",
|
16 |
+
OAUTH_CLIENT_SECRET: "oauth_client_secret",
|
17 |
+
OAUTH_AUTHORIZATION_ENDPOINT: "oauth_authorization_endpoint",
|
18 |
+
OAUTH_TOKEN_ENDPOINT: "oauth_token_endpoint",
|
19 |
+
OAUTH_REDIRECT_URI: "oauth_redirect_uri",
|
20 |
+
OAUTH_RESOURCE: "oauth_resource",
|
21 |
+
OAUTH_ACCESS_TOKEN: "oauth_access_token",
|
22 |
+
OAUTH_CODE_VERIFIER: "oauth_code_verifier",
|
23 |
+
OAUTH_MCP_SERVER_URL: "oauth_mcp_server_url",
|
24 |
+
OAUTH_AUTHORIZATION_SERVER_METADATA: "oauth_authorization_server_metadata",
|
25 |
+
MCP_SERVER_NAME: "mcp_server_name",
|
26 |
+
MCP_SERVER_TRANSPORT: "mcp_server_transport",
|
27 |
+
} as const;
|
28 |
+
|
29 |
+
// Default Values
|
30 |
+
export const DEFAULTS = {
|
31 |
+
MCP_TRANSPORT: "streamable-http" as const,
|
32 |
+
OAUTH_REDIRECT_PATH: "/oauth/callback",
|
33 |
+
NOTIFICATION_TIMEOUT: 3000,
|
34 |
+
OAUTH_ERROR_TIMEOUT: 5000,
|
35 |
+
} as const;
|
src/utils/storage.ts
ADDED
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Secure storage utilities for sensitive data like OAuth tokens
|
3 |
+
*/
|
4 |
+
|
5 |
+
const ENCRYPTION_KEY_NAME = 'mcp-encryption-key';
|
6 |
+
|
7 |
+
// Generate or retrieve encryption key
|
8 |
+
async function getEncryptionKey(): Promise<CryptoKey> {
|
9 |
+
const keyData = localStorage.getItem(ENCRYPTION_KEY_NAME);
|
10 |
+
|
11 |
+
if (keyData) {
|
12 |
+
try {
|
13 |
+
const keyBuffer = new Uint8Array(JSON.parse(keyData));
|
14 |
+
return await crypto.subtle.importKey(
|
15 |
+
'raw',
|
16 |
+
keyBuffer,
|
17 |
+
{ name: 'AES-GCM' },
|
18 |
+
false,
|
19 |
+
['encrypt', 'decrypt']
|
20 |
+
);
|
21 |
+
} catch {
|
22 |
+
// Key corrupted, generate new one
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
// Generate new key
|
27 |
+
const key = await crypto.subtle.generateKey(
|
28 |
+
{ name: 'AES-GCM', length: 256 },
|
29 |
+
true,
|
30 |
+
['encrypt', 'decrypt']
|
31 |
+
);
|
32 |
+
|
33 |
+
// Store key for future use
|
34 |
+
const keyBuffer = await crypto.subtle.exportKey('raw', key);
|
35 |
+
localStorage.setItem(ENCRYPTION_KEY_NAME, JSON.stringify(Array.from(new Uint8Array(keyBuffer))));
|
36 |
+
|
37 |
+
return key;
|
38 |
+
}
|
39 |
+
|
40 |
+
// Encrypt sensitive data
|
41 |
+
export async function encryptData(data: string): Promise<string> {
|
42 |
+
try {
|
43 |
+
const key = await getEncryptionKey();
|
44 |
+
const encoder = new TextEncoder();
|
45 |
+
const dataBuffer = encoder.encode(data);
|
46 |
+
|
47 |
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
48 |
+
const encryptedBuffer = await crypto.subtle.encrypt(
|
49 |
+
{ name: 'AES-GCM', iv },
|
50 |
+
key,
|
51 |
+
dataBuffer
|
52 |
+
);
|
53 |
+
|
54 |
+
// Combine IV and encrypted data
|
55 |
+
const result = new Uint8Array(iv.length + encryptedBuffer.byteLength);
|
56 |
+
result.set(iv);
|
57 |
+
result.set(new Uint8Array(encryptedBuffer), iv.length);
|
58 |
+
|
59 |
+
return btoa(String.fromCharCode(...result));
|
60 |
+
} catch (error) {
|
61 |
+
console.warn('Encryption failed, storing data unencrypted:', error);
|
62 |
+
return data;
|
63 |
+
}
|
64 |
+
}
|
65 |
+
|
66 |
+
// Decrypt sensitive data
|
67 |
+
export async function decryptData(encryptedData: string): Promise<string> {
|
68 |
+
try {
|
69 |
+
const key = await getEncryptionKey();
|
70 |
+
const dataBuffer = new Uint8Array(
|
71 |
+
atob(encryptedData).split('').map(char => char.charCodeAt(0))
|
72 |
+
);
|
73 |
+
|
74 |
+
const iv = dataBuffer.slice(0, 12);
|
75 |
+
const encrypted = dataBuffer.slice(12);
|
76 |
+
|
77 |
+
const decryptedBuffer = await crypto.subtle.decrypt(
|
78 |
+
{ name: 'AES-GCM', iv },
|
79 |
+
key,
|
80 |
+
encrypted
|
81 |
+
);
|
82 |
+
|
83 |
+
const decoder = new TextDecoder();
|
84 |
+
return decoder.decode(decryptedBuffer);
|
85 |
+
} catch (error) {
|
86 |
+
console.warn('Decryption failed, returning data as-is:', error);
|
87 |
+
return encryptedData;
|
88 |
+
}
|
89 |
+
}
|
90 |
+
|
91 |
+
// Secure storage wrapper for sensitive data
|
92 |
+
export const secureStorage = {
|
93 |
+
async setItem(key: string, value: string): Promise<void> {
|
94 |
+
const encrypted = await encryptData(value);
|
95 |
+
localStorage.setItem(`secure_${key}`, encrypted);
|
96 |
+
},
|
97 |
+
|
98 |
+
async getItem(key: string): Promise<string | null> {
|
99 |
+
const encrypted = localStorage.getItem(`secure_${key}`);
|
100 |
+
if (!encrypted) return null;
|
101 |
+
|
102 |
+
return await decryptData(encrypted);
|
103 |
+
},
|
104 |
+
|
105 |
+
removeItem(key: string): void {
|
106 |
+
localStorage.removeItem(`secure_${key}`);
|
107 |
+
}
|
108 |
+
};
|