pi-mcp-adapter 2.2.2 → 2.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +1 -2
- package/commands.ts +48 -20
- package/direct-tools.ts +7 -0
- package/index.ts +5 -0
- package/init.ts +20 -4
- package/mcp-auth-flow.ts +378 -0
- package/mcp-auth.ts +277 -0
- package/mcp-callback-server.ts +251 -0
- package/mcp-oauth-provider.ts +265 -0
- package/package.json +16 -8
- package/proxy-modes.ts +31 -0
- package/server-manager.ts +66 -24
- package/types.ts +27 -1
package/mcp-auth.ts
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auth Storage Module
|
|
3
|
+
*
|
|
4
|
+
* Handles secure storage of OAuth credentials, tokens, client information,
|
|
5
|
+
* and PKCE state for MCP servers. Maintains backward compatibility with
|
|
6
|
+
* per-server directory structure.
|
|
7
|
+
*
|
|
8
|
+
* Token storage location: ~/.pi/agent/mcp-oauth/<server>/tokens.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
import { homedir } from 'os';
|
|
14
|
+
|
|
15
|
+
/** OAuth token storage format */
|
|
16
|
+
export interface StoredTokens {
|
|
17
|
+
accessToken: string;
|
|
18
|
+
refreshToken?: string;
|
|
19
|
+
expiresAt?: number; // Unix timestamp in seconds
|
|
20
|
+
scope?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** OAuth client information from dynamic or static registration */
|
|
24
|
+
export interface StoredClientInfo {
|
|
25
|
+
clientId: string;
|
|
26
|
+
clientSecret?: string;
|
|
27
|
+
clientIdIssuedAt?: number;
|
|
28
|
+
clientSecretExpiresAt?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Complete auth entry for a server */
|
|
32
|
+
export interface AuthEntry {
|
|
33
|
+
tokens?: StoredTokens;
|
|
34
|
+
clientInfo?: StoredClientInfo;
|
|
35
|
+
codeVerifier?: string;
|
|
36
|
+
oauthState?: string;
|
|
37
|
+
serverUrl?: string; // Track the URL these credentials are for
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Base directory for auth storage - can be overridden via env var for testing
|
|
41
|
+
function getAuthBaseDir(): string {
|
|
42
|
+
return process.env.MCP_OAUTH_DIR
|
|
43
|
+
? process.env.MCP_OAUTH_DIR
|
|
44
|
+
: join(homedir(), '.pi', 'agent', 'mcp-oauth');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the server-specific directory path.
|
|
49
|
+
*/
|
|
50
|
+
function getServerDir(serverName: string): string {
|
|
51
|
+
return join(getAuthBaseDir(), serverName);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the tokens file path for a server.
|
|
56
|
+
*/
|
|
57
|
+
function getTokensFilePath(serverName: string): string {
|
|
58
|
+
return join(getServerDir(serverName), 'tokens.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ensure the server directory exists with secure permissions.
|
|
63
|
+
*/
|
|
64
|
+
function ensureServerDir(serverName: string): void {
|
|
65
|
+
const dir = getServerDir(serverName);
|
|
66
|
+
if (!existsSync(dir)) {
|
|
67
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read the auth entry for a server from disk.
|
|
73
|
+
* Returns undefined if file doesn't exist.
|
|
74
|
+
*/
|
|
75
|
+
function readAuthEntry(serverName: string): AuthEntry | undefined {
|
|
76
|
+
try {
|
|
77
|
+
const filePath = getTokensFilePath(serverName);
|
|
78
|
+
if (!existsSync(filePath)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const data = readFileSync(filePath, 'utf-8');
|
|
82
|
+
return JSON.parse(data) as AuthEntry;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error(`Failed to read auth entry for ${serverName}:`, error);
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Write the auth entry for a server to disk with secure permissions.
|
|
91
|
+
*/
|
|
92
|
+
function writeAuthEntry(serverName: string, entry: AuthEntry): void {
|
|
93
|
+
ensureServerDir(serverName);
|
|
94
|
+
const filePath = getTokensFilePath(serverName);
|
|
95
|
+
writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get auth entry for a server.
|
|
100
|
+
*/
|
|
101
|
+
export function getAuthEntry(serverName: string): AuthEntry | undefined {
|
|
102
|
+
return readAuthEntry(serverName);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get auth entry and validate it's for the correct URL.
|
|
107
|
+
* Returns undefined if URL has changed (credentials are invalid).
|
|
108
|
+
*/
|
|
109
|
+
export function getAuthForUrl(serverName: string, serverUrl: string): AuthEntry | undefined {
|
|
110
|
+
const entry = getAuthEntry(serverName);
|
|
111
|
+
if (!entry) return undefined;
|
|
112
|
+
|
|
113
|
+
// If no serverUrl is stored, this is from an old version - consider it invalid
|
|
114
|
+
if (!entry.serverUrl) return undefined;
|
|
115
|
+
|
|
116
|
+
// If URL has changed, credentials are invalid
|
|
117
|
+
if (entry.serverUrl !== serverUrl) return undefined;
|
|
118
|
+
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Save auth entry for a server.
|
|
124
|
+
*/
|
|
125
|
+
export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void {
|
|
126
|
+
// Always update serverUrl if provided
|
|
127
|
+
if (serverUrl) {
|
|
128
|
+
entry.serverUrl = serverUrl;
|
|
129
|
+
}
|
|
130
|
+
writeAuthEntry(serverName, entry);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove auth entry for a server.
|
|
135
|
+
* Also removes the server directory if empty.
|
|
136
|
+
*/
|
|
137
|
+
export function removeAuthEntry(serverName: string): void {
|
|
138
|
+
try {
|
|
139
|
+
const filePath = getTokensFilePath(serverName);
|
|
140
|
+
if (existsSync(filePath)) {
|
|
141
|
+
writeFileSync(filePath, '{}', { mode: 0o600 });
|
|
142
|
+
}
|
|
143
|
+
// Try to remove the directory
|
|
144
|
+
const dir = getServerDir(serverName);
|
|
145
|
+
if (existsSync(dir)) {
|
|
146
|
+
try {
|
|
147
|
+
rmSync(dir, { recursive: true });
|
|
148
|
+
} catch {
|
|
149
|
+
// Directory may not be empty, ignore
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error(`Failed to remove auth entry for ${serverName}:`, error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Update tokens for a server.
|
|
159
|
+
*/
|
|
160
|
+
export function updateTokens(
|
|
161
|
+
serverName: string,
|
|
162
|
+
tokens: StoredTokens,
|
|
163
|
+
serverUrl?: string
|
|
164
|
+
): void {
|
|
165
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
166
|
+
entry.tokens = tokens;
|
|
167
|
+
saveAuthEntry(serverName, entry, serverUrl);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Update client info for a server.
|
|
172
|
+
*/
|
|
173
|
+
export function updateClientInfo(
|
|
174
|
+
serverName: string,
|
|
175
|
+
clientInfo: StoredClientInfo,
|
|
176
|
+
serverUrl?: string
|
|
177
|
+
): void {
|
|
178
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
179
|
+
entry.clientInfo = clientInfo;
|
|
180
|
+
saveAuthEntry(serverName, entry, serverUrl);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Update code verifier for a server.
|
|
185
|
+
*/
|
|
186
|
+
export function updateCodeVerifier(serverName: string, codeVerifier: string): void {
|
|
187
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
188
|
+
entry.codeVerifier = codeVerifier;
|
|
189
|
+
saveAuthEntry(serverName, entry);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Clear code verifier for a server.
|
|
194
|
+
*/
|
|
195
|
+
export function clearCodeVerifier(serverName: string): void {
|
|
196
|
+
const entry = getAuthEntry(serverName);
|
|
197
|
+
if (entry) {
|
|
198
|
+
delete entry.codeVerifier;
|
|
199
|
+
saveAuthEntry(serverName, entry);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Update OAuth state for a server.
|
|
205
|
+
*/
|
|
206
|
+
export function updateOAuthState(serverName: string, state: string): void {
|
|
207
|
+
const entry = getAuthEntry(serverName) ?? {};
|
|
208
|
+
entry.oauthState = state;
|
|
209
|
+
saveAuthEntry(serverName, entry);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get OAuth state for a server.
|
|
214
|
+
*/
|
|
215
|
+
export function getOAuthState(serverName: string): string | undefined {
|
|
216
|
+
const entry = getAuthEntry(serverName);
|
|
217
|
+
return entry?.oauthState;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Clear OAuth state for a server.
|
|
222
|
+
*/
|
|
223
|
+
export function clearOAuthState(serverName: string): void {
|
|
224
|
+
const entry = getAuthEntry(serverName);
|
|
225
|
+
if (entry) {
|
|
226
|
+
delete entry.oauthState;
|
|
227
|
+
saveAuthEntry(serverName, entry);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Check if stored tokens are expired.
|
|
233
|
+
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
|
234
|
+
*/
|
|
235
|
+
export function isTokenExpired(serverName: string): boolean | null {
|
|
236
|
+
const entry = getAuthEntry(serverName);
|
|
237
|
+
if (!entry?.tokens) return null;
|
|
238
|
+
if (!entry.tokens.expiresAt) return false;
|
|
239
|
+
return entry.tokens.expiresAt < Date.now() / 1000;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if a server has stored tokens.
|
|
244
|
+
*/
|
|
245
|
+
export function hasStoredTokens(serverName: string): boolean {
|
|
246
|
+
const entry = getAuthEntry(serverName);
|
|
247
|
+
return !!entry?.tokens;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Clear all credentials for a server.
|
|
252
|
+
*/
|
|
253
|
+
export function clearAllCredentials(serverName: string): void {
|
|
254
|
+
removeAuthEntry(serverName);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Clear only client info for a server.
|
|
259
|
+
*/
|
|
260
|
+
export function clearClientInfo(serverName: string): void {
|
|
261
|
+
const entry = getAuthEntry(serverName);
|
|
262
|
+
if (entry) {
|
|
263
|
+
delete entry.clientInfo;
|
|
264
|
+
saveAuthEntry(serverName, entry);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clear only tokens for a server.
|
|
270
|
+
*/
|
|
271
|
+
export function clearTokens(serverName: string): void {
|
|
272
|
+
const entry = getAuthEntry(serverName);
|
|
273
|
+
if (entry) {
|
|
274
|
+
delete entry.tokens;
|
|
275
|
+
saveAuthEntry(serverName, entry);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Callback Server
|
|
3
|
+
*
|
|
4
|
+
* HTTP server that handles OAuth callbacks from the authorization server.
|
|
5
|
+
* Uses Node.js http module for compatibility.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http"
|
|
9
|
+
import { connect } from "net"
|
|
10
|
+
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./mcp-oauth-provider.js"
|
|
11
|
+
|
|
12
|
+
// HTML templates for callback responses
|
|
13
|
+
const HTML_SUCCESS = `<!DOCTYPE html>
|
|
14
|
+
<html>
|
|
15
|
+
<head>
|
|
16
|
+
<title>Pi - Authorization Successful</title>
|
|
17
|
+
<style>
|
|
18
|
+
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
|
19
|
+
.container { text-align: center; padding: 2rem; }
|
|
20
|
+
h1 { color: #4ade80; margin-bottom: 1rem; }
|
|
21
|
+
p { color: #aaa; }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
<div class="container">
|
|
26
|
+
<h1>Authorization Successful</h1>
|
|
27
|
+
<p>You can close this window and return to Pi.</p>
|
|
28
|
+
</div>
|
|
29
|
+
<script>setTimeout(() => window.close(), 2000);</script>
|
|
30
|
+
</body>
|
|
31
|
+
</html>`
|
|
32
|
+
|
|
33
|
+
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
34
|
+
<html>
|
|
35
|
+
<head>
|
|
36
|
+
<title>Pi - Authorization Failed</title>
|
|
37
|
+
<style>
|
|
38
|
+
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #1a1a2e; color: #eee; }
|
|
39
|
+
.container { text-align: center; padding: 2rem; }
|
|
40
|
+
h1 { color: #f87171; margin-bottom: 1rem; }
|
|
41
|
+
p { color: #aaa; }
|
|
42
|
+
.error { color: #fca5a5; font-family: monospace; margin-top: 1rem; padding: 1rem; background: rgba(248,113,113,0.1); border-radius: 0.5rem; }
|
|
43
|
+
</style>
|
|
44
|
+
</head>
|
|
45
|
+
<body>
|
|
46
|
+
<div class="container">
|
|
47
|
+
<h1>Authorization Failed</h1>
|
|
48
|
+
<p>An error occurred during authorization.</p>
|
|
49
|
+
<div class="error">${error}</div>
|
|
50
|
+
</div>
|
|
51
|
+
</body>
|
|
52
|
+
</html>`
|
|
53
|
+
|
|
54
|
+
/** Pending authorization request */
|
|
55
|
+
interface PendingAuth {
|
|
56
|
+
resolve: (code: string) => void
|
|
57
|
+
reject: (error: Error) => void
|
|
58
|
+
timeout: ReturnType<typeof setTimeout>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Server singleton state */
|
|
62
|
+
let server: Server | undefined
|
|
63
|
+
const pendingAuths = new Map<string, PendingAuth>()
|
|
64
|
+
|
|
65
|
+
/** Timeout for callback completion (5 minutes) */
|
|
66
|
+
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Check if the callback port is already in use.
|
|
70
|
+
* Used to detect if another instance is running the server.
|
|
71
|
+
*/
|
|
72
|
+
async function isPortInUse(): Promise<boolean> {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
const socket = connect({ host: "127.0.0.1", port: OAUTH_CALLBACK_PORT })
|
|
75
|
+
socket.setTimeout(1000)
|
|
76
|
+
|
|
77
|
+
socket.on("connect", () => {
|
|
78
|
+
socket.end()
|
|
79
|
+
resolve(true)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
socket.on("error", () => {
|
|
83
|
+
resolve(false)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
socket.on("timeout", () => {
|
|
87
|
+
socket.destroy()
|
|
88
|
+
resolve(false)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handle incoming HTTP requests to the callback server.
|
|
95
|
+
*/
|
|
96
|
+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
97
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`)
|
|
98
|
+
|
|
99
|
+
// Only handle the callback path
|
|
100
|
+
if (url.pathname !== OAUTH_CALLBACK_PATH) {
|
|
101
|
+
res.writeHead(404, { "Content-Type": "text/plain" })
|
|
102
|
+
res.end("Not found")
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const code = url.searchParams.get("code")
|
|
107
|
+
const state = url.searchParams.get("state")
|
|
108
|
+
const error = url.searchParams.get("error")
|
|
109
|
+
const errorDescription = url.searchParams.get("error_description")
|
|
110
|
+
|
|
111
|
+
// Enforce state parameter presence for CSRF protection
|
|
112
|
+
if (!state) {
|
|
113
|
+
const errorMsg = "Missing required state parameter - potential CSRF attack"
|
|
114
|
+
res.writeHead(400, { "Content-Type": "text/html" })
|
|
115
|
+
res.end(HTML_ERROR(errorMsg))
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Handle OAuth errors
|
|
120
|
+
if (error) {
|
|
121
|
+
const errorMsg = errorDescription || error
|
|
122
|
+
// Send HTTP response first before rejecting promise
|
|
123
|
+
res.writeHead(200, { "Content-Type": "text/html" })
|
|
124
|
+
res.end(HTML_ERROR(errorMsg))
|
|
125
|
+
// Reject promise after response is sent (defer to allow test to attach handler)
|
|
126
|
+
if (pendingAuths.has(state)) {
|
|
127
|
+
const pending = pendingAuths.get(state)!
|
|
128
|
+
clearTimeout(pending.timeout)
|
|
129
|
+
pendingAuths.delete(state)
|
|
130
|
+
setTimeout(() => pending.reject(new Error(errorMsg)), 0)
|
|
131
|
+
}
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Require authorization code
|
|
136
|
+
if (!code) {
|
|
137
|
+
res.writeHead(400, { "Content-Type": "text/html" })
|
|
138
|
+
res.end(HTML_ERROR("No authorization code provided"))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Validate state parameter
|
|
143
|
+
if (!pendingAuths.has(state)) {
|
|
144
|
+
const errorMsg = "Invalid or expired state parameter - potential CSRF attack"
|
|
145
|
+
res.writeHead(400, { "Content-Type": "text/html" })
|
|
146
|
+
res.end(HTML_ERROR(errorMsg))
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pending = pendingAuths.get(state)!
|
|
151
|
+
|
|
152
|
+
// Clear timeout and resolve the pending promise
|
|
153
|
+
clearTimeout(pending.timeout)
|
|
154
|
+
pendingAuths.delete(state)
|
|
155
|
+
pending.resolve(code)
|
|
156
|
+
|
|
157
|
+
res.writeHead(200, { "Content-Type": "text/html" })
|
|
158
|
+
res.end(HTML_SUCCESS)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Ensure the callback server is running.
|
|
163
|
+
* If the port is in use by another process, fail fast with a clear error.
|
|
164
|
+
*/
|
|
165
|
+
export async function ensureCallbackServer(): Promise<void> {
|
|
166
|
+
if (server) return
|
|
167
|
+
|
|
168
|
+
const running = await isPortInUse()
|
|
169
|
+
if (running) {
|
|
170
|
+
throw new Error(`OAuth callback port ${OAUTH_CALLBACK_PORT} is already in use`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
server = createServer(handleRequest)
|
|
174
|
+
|
|
175
|
+
await new Promise<void>((resolve, reject) => {
|
|
176
|
+
server!.once("error", (err) => {
|
|
177
|
+
reject(err)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
server!.listen(OAUTH_CALLBACK_PORT, "127.0.0.1", () => {
|
|
181
|
+
resolve()
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Wait for a callback with the given OAuth state.
|
|
188
|
+
* Returns a promise that resolves with the authorization code.
|
|
189
|
+
*/
|
|
190
|
+
export function waitForCallback(oauthState: string): Promise<string> {
|
|
191
|
+
return new Promise((resolve, reject) => {
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
if (pendingAuths.has(oauthState)) {
|
|
194
|
+
pendingAuths.delete(oauthState)
|
|
195
|
+
reject(new Error("OAuth callback timeout - authorization took too long"))
|
|
196
|
+
}
|
|
197
|
+
}, CALLBACK_TIMEOUT_MS)
|
|
198
|
+
|
|
199
|
+
pendingAuths.set(oauthState, { resolve, reject, timeout })
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Cancel a pending authorization by state.
|
|
205
|
+
*/
|
|
206
|
+
export function cancelPendingCallback(oauthState: string): void {
|
|
207
|
+
const pending = pendingAuths.get(oauthState)
|
|
208
|
+
if (pending) {
|
|
209
|
+
clearTimeout(pending.timeout)
|
|
210
|
+
pendingAuths.delete(oauthState)
|
|
211
|
+
pending.reject(new Error("Authorization cancelled"))
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Stop the callback server and reject all pending authorizations.
|
|
217
|
+
*/
|
|
218
|
+
export async function stopCallbackServer(): Promise<void> {
|
|
219
|
+
if (server) {
|
|
220
|
+
await new Promise<void>((resolve) => {
|
|
221
|
+
server!.close(() => {
|
|
222
|
+
resolve()
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
server = undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Reject all pending auths (defer to allow any pending operations to complete)
|
|
229
|
+
const pendingList = Array.from(pendingAuths.entries())
|
|
230
|
+
pendingAuths.clear()
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
for (const [state, pending] of pendingList) {
|
|
233
|
+
clearTimeout(pending.timeout)
|
|
234
|
+
pending.reject(new Error("OAuth callback server stopped"))
|
|
235
|
+
}
|
|
236
|
+
}, 0)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Check if the callback server is running.
|
|
241
|
+
*/
|
|
242
|
+
export function isCallbackServerRunning(): boolean {
|
|
243
|
+
return server !== undefined
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get the number of pending authorizations.
|
|
248
|
+
*/
|
|
249
|
+
export function getPendingAuthCount(): number {
|
|
250
|
+
return pendingAuths.size
|
|
251
|
+
}
|