-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcallback-server.ts
More file actions
189 lines (163 loc) · 6.24 KB
/
Copy pathcallback-server.ts
File metadata and controls
189 lines (163 loc) · 6.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
import { createServer as createHttpServer, type Server } from 'http';
import { URL } from 'url';
import { generateCallbackPage, type AppType } from './callback-page.ts';
// Re-export for backwards compatibility
export { generateCallbackPage, type AppType } from './callback-page.ts';
const START_PORT = 6477;
const MAX_PORT_ATTEMPTS = 100;
export interface CallbackPayload {
// For now just the query params. In the future we may extend this with other request properties.
query: Record<string, string>;
}
export interface CallbackServer {
promise: Promise<CallbackPayload>;
url: string;
/** Close the callback server. Call this on component unmount to clean up. */
close: () => void | Promise<void>;
}
/**
* Attempt to bind an HTTP server to the given port.
* Resolves on success, rejects on error (e.g. EADDRINUSE).
*/
function tryBind(server: Server, port: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
server.once('error', reject);
// Use 'localhost' consistently for both the bind address and the URL
// that callers construct (avoids subtle mismatches between 127.0.0.1 and localhost).
server.listen(port, 'localhost', () => {
server.removeListener('error', reject);
resolve();
});
});
}
export interface CreateCallbackServerOptions {
appType?: AppType;
/** Deep link URL to redirect to after successful auth (e.g., craftagents://auth-complete) */
deeplinkUrl?: string;
/** Fixed port to bind to. If set, only that port is tried (no range scanning). */
port?: number;
/** URL paths to accept as callbacks. Default: ['/callback', '/oauth/callback']. */
callbackPaths?: string[];
}
/**
* Creates an OAuth callback server by binding directly to a port in the range
* START_PORT .. START_PORT + MAX_PORT_ATTEMPTS - 1.
*
* Unlike a check-then-bind approach, this eliminates the TOCTOU race condition
* by attempting to bind the real server on each candidate port. If the port is
* already in use (EADDRINUSE), the server is closed and the next port is tried.
*/
export async function createCallbackServer(options?: CreateCallbackServerOptions): Promise<CallbackServer> {
const appType = options?.appType ?? 'terminal';
const deeplinkUrl = options?.deeplinkUrl;
const allowedPaths = new Set(options?.callbackPaths ?? ['/callback', '/oauth/callback']);
let server: Server | null = null;
let boundPort: number | null = null;
let resolveCallback: ((payload: CallbackPayload) => void) | null = null;
let rejectCallback: ((error: Error) => void) | null = null;
const callbackPromise = new Promise<CallbackPayload>((resolve, reject) => {
resolveCallback = resolve;
rejectCallback = reject;
});
// Build the request handler. It closes over `boundPort` which is set before
// any requests can arrive (the browser isn't opened until after we return).
const requestHandler = async (req: import('http').IncomingMessage, res: import('http').ServerResponse) => {
try {
const url = new URL(req.url || '/', `http://localhost:${boundPort}`);
if (!allowedPaths.has(url.pathname)) {
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
res.end('Not found');
return;
}
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
const payload: CallbackPayload = {
query,
};
// Check if this looks like a successful auth callback
const hasCode = !!query.code;
const hasError = !!query.error;
// Send a styled success/error page
const html = generateCallbackPage({
title: hasError ? 'Authorization Failed' : 'Authorization Complete',
isSuccess: hasCode && !hasError,
errorDetail: query.error_description || query.error,
appType,
deeplinkUrl: (hasCode && !hasError) ? deeplinkUrl : undefined,
});
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
if (server) {
server.close();
server = null;
}
if (resolveCallback) {
resolveCallback(payload);
}
} catch (error) {
const html = generateCallbackPage({
title: 'Error',
isSuccess: false,
errorDetail: error instanceof Error ? error.message : 'Internal Server Error',
appType,
});
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
if (rejectCallback) {
rejectCallback(error instanceof Error ? error : new Error(String(error)));
}
} finally {
if (server) {
server.close();
server = null;
}
}
};
// Port selection: fixed port (options.port) or scan default range.
const fixedPort = options?.port;
const portStart = fixedPort ?? START_PORT;
const portAttempts = fixedPort != null ? 1 : MAX_PORT_ATTEMPTS;
for (let i = 0; i < portAttempts; i++) {
const port = portStart + i;
const candidate = createHttpServer(requestHandler);
try {
await tryBind(candidate, port);
// Bind succeeded — wire up the error handler for runtime errors
// and propagate them to the callback promise.
server = candidate;
boundPort = port;
server.on('error', (err) => {
rejectCallback?.(err instanceof Error ? err : new Error(String(err)));
});
break;
} catch (err: unknown) {
// Port in use — close the candidate and try the next one
candidate.close();
const isAddressInUse =
err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'EADDRINUSE';
if (!isAddressInUse) {
// Unexpected error (e.g. permission denied) — propagate immediately
throw err instanceof Error ? err : new Error(String(err));
}
}
}
if (server === null || boundPort === null) {
if (fixedPort != null) {
throw new Error(`Port ${fixedPort} is already in use`);
}
throw new Error(`No available port found in range ${START_PORT}-${START_PORT + MAX_PORT_ATTEMPTS - 1}`);
}
const callbackUrl = `http://localhost:${boundPort}`;
return {
promise: callbackPromise,
url: callbackUrl,
close: () => {
if (server) {
server.close();
server = null;
}
},
};
}