@neuralnomads/codenomad 0.6.0 → 0.7.1
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/dist/auth/auth-store.js +134 -0
- package/dist/auth/http-auth.js +37 -0
- package/dist/auth/manager.js +87 -0
- package/dist/auth/password-hash.js +32 -0
- package/dist/auth/session-manager.js +17 -0
- package/dist/auth/token-manager.js +27 -0
- package/dist/index.js +30 -2
- package/dist/opencode-config/package.json +1 -1
- package/dist/opencode-config/plugin/lib/background-process.ts +14 -70
- package/dist/opencode-config/plugin/lib/client.ts +22 -54
- package/dist/opencode-config/plugin/lib/request.ts +124 -0
- package/dist/server/http-server.js +102 -5
- package/dist/server/routes/auth-pages/login.html +134 -0
- package/dist/server/routes/auth-pages/token.html +93 -0
- package/dist/server/routes/auth.js +128 -0
- package/dist/workspaces/instance-events.js +6 -1
- package/dist/workspaces/manager.js +23 -1
- package/dist/workspaces/opencode-auth.js +16 -0
- package/dist/workspaces/runtime.js +13 -1
- package/package.json +3 -3
- package/public/assets/index-DhjiW0WU.css +1 -0
- package/public/assets/{loading-BIWzmJ53.js → loading-A3c3OC91.js} +1 -1
- package/public/assets/main-C3qD3Vm8.js +184 -0
- package/public/index.html +3 -3
- package/public/loading.html +3 -3
- package/public/assets/index-CKQiPGtF.css +0 -1
- package/public/assets/main-BSwx5oHC.js +0 -184
- /package/public/assets/{index-BC0I6SzM.js → index-D4j6wgeo.js} +0 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export type PluginEvent = {
|
|
2
|
+
type: string
|
|
3
|
+
properties?: Record<string, unknown>
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type CodeNomadConfig = {
|
|
7
|
+
instanceId: string
|
|
8
|
+
baseUrl: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getCodeNomadConfig(): CodeNomadConfig {
|
|
12
|
+
return {
|
|
13
|
+
instanceId: requireEnv("CODENOMAD_INSTANCE_ID"),
|
|
14
|
+
baseUrl: requireEnv("CODENOMAD_BASE_URL"),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createCodeNomadRequester(config: CodeNomadConfig) {
|
|
19
|
+
const baseUrl = config.baseUrl.replace(/\/+$/, "")
|
|
20
|
+
const pluginBase = `${baseUrl}/workspaces/${encodeURIComponent(config.instanceId)}/plugin`
|
|
21
|
+
const authorization = buildInstanceAuthorizationHeader()
|
|
22
|
+
|
|
23
|
+
const buildUrl = (path: string) => {
|
|
24
|
+
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
25
|
+
return path
|
|
26
|
+
}
|
|
27
|
+
const normalized = path.startsWith("/") ? path : `/${path}`
|
|
28
|
+
return `${pluginBase}${normalized}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const buildHeaders = (headers: HeadersInit | undefined, hasBody: boolean): Record<string, string> => {
|
|
32
|
+
const output: Record<string, string> = normalizeHeaders(headers)
|
|
33
|
+
output.Authorization = authorization
|
|
34
|
+
if (hasBody) {
|
|
35
|
+
output["Content-Type"] = output["Content-Type"] ?? "application/json"
|
|
36
|
+
}
|
|
37
|
+
return output
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fetchWithAuth = async (path: string, init?: RequestInit): Promise<Response> => {
|
|
41
|
+
const url = buildUrl(path)
|
|
42
|
+
const hasBody = init?.body !== undefined
|
|
43
|
+
const headers = buildHeaders(init?.headers, hasBody)
|
|
44
|
+
|
|
45
|
+
return fetch(url, {
|
|
46
|
+
...init,
|
|
47
|
+
headers,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const requestJson = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
52
|
+
const response = await fetchWithAuth(path, init)
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
const message = await response.text().catch(() => "")
|
|
55
|
+
throw new Error(message || `Request failed with ${response.status}`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (response.status === 204) {
|
|
59
|
+
return undefined as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return (await response.json()) as T
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const requestVoid = async (path: string, init?: RequestInit): Promise<void> => {
|
|
66
|
+
const response = await fetchWithAuth(path, init)
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
const message = await response.text().catch(() => "")
|
|
69
|
+
throw new Error(message || `Request failed with ${response.status}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const requestSseBody = async (path: string): Promise<ReadableStream<Uint8Array>> => {
|
|
74
|
+
const response = await fetchWithAuth(path, { headers: { Accept: "text/event-stream" } })
|
|
75
|
+
if (!response.ok || !response.body) {
|
|
76
|
+
throw new Error(`SSE unavailable (${response.status})`)
|
|
77
|
+
}
|
|
78
|
+
return response.body as ReadableStream<Uint8Array>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
buildUrl,
|
|
83
|
+
fetch: fetchWithAuth,
|
|
84
|
+
requestJson,
|
|
85
|
+
requestVoid,
|
|
86
|
+
requestSseBody,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireEnv(key: string): string {
|
|
91
|
+
const value = process.env[key]
|
|
92
|
+
if (!value || !value.trim()) {
|
|
93
|
+
throw new Error(`[CodeNomadPlugin] Missing required env var ${key}`)
|
|
94
|
+
}
|
|
95
|
+
return value
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function buildInstanceAuthorizationHeader(): string {
|
|
99
|
+
const username = requireEnv("OPENCODE_SERVER_USERNAME")
|
|
100
|
+
const password = requireEnv("OPENCODE_SERVER_PASSWORD")
|
|
101
|
+
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64")
|
|
102
|
+
return `Basic ${token}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
106
|
+
const output: Record<string, string> = {}
|
|
107
|
+
if (!headers) return output
|
|
108
|
+
|
|
109
|
+
if (headers instanceof Headers) {
|
|
110
|
+
headers.forEach((value, key) => {
|
|
111
|
+
output[key] = value
|
|
112
|
+
})
|
|
113
|
+
return output
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (Array.isArray(headers)) {
|
|
117
|
+
for (const [key, value] of headers) {
|
|
118
|
+
output[key] = value
|
|
119
|
+
}
|
|
120
|
+
return output
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { ...headers }
|
|
124
|
+
}
|
|
@@ -14,6 +14,8 @@ import { registerStorageRoutes } from "./routes/storage";
|
|
|
14
14
|
import { registerPluginRoutes } from "./routes/plugin";
|
|
15
15
|
import { registerBackgroundProcessRoutes } from "./routes/background-processes";
|
|
16
16
|
import { BackgroundProcessManager } from "../background-processes/manager";
|
|
17
|
+
import { registerAuthRoutes } from "./routes/auth";
|
|
18
|
+
import { sendUnauthorized, wantsHtml } from "../auth/http-auth";
|
|
17
19
|
const DEFAULT_HTTP_PORT = 9898;
|
|
18
20
|
export function createHttpServer(deps) {
|
|
19
21
|
const app = Fastify({ logger: false });
|
|
@@ -53,8 +55,30 @@ export function createHttpServer(deps) {
|
|
|
53
55
|
}
|
|
54
56
|
done();
|
|
55
57
|
});
|
|
58
|
+
const allowedDevOrigins = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]);
|
|
56
59
|
app.register(cors, {
|
|
57
|
-
origin:
|
|
60
|
+
origin: (origin, cb) => {
|
|
61
|
+
if (!origin) {
|
|
62
|
+
cb(null, true);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let selfOrigin = null;
|
|
66
|
+
try {
|
|
67
|
+
selfOrigin = new URL(deps.serverMeta.httpBaseUrl).origin;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
selfOrigin = null;
|
|
71
|
+
}
|
|
72
|
+
if (selfOrigin && origin === selfOrigin) {
|
|
73
|
+
cb(null, true);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (allowedDevOrigins.has(origin)) {
|
|
77
|
+
cb(null, true);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
cb(null, false);
|
|
81
|
+
},
|
|
58
82
|
credentials: true,
|
|
59
83
|
});
|
|
60
84
|
app.register(replyFrom, {
|
|
@@ -71,6 +95,62 @@ export function createHttpServer(deps) {
|
|
|
71
95
|
eventBus: deps.eventBus,
|
|
72
96
|
logger: deps.logger.child({ component: "background-processes" }),
|
|
73
97
|
});
|
|
98
|
+
registerAuthRoutes(app, { authManager: deps.authManager });
|
|
99
|
+
app.addHook("preHandler", (request, reply, done) => {
|
|
100
|
+
const rawUrl = request.raw.url ?? request.url;
|
|
101
|
+
const pathname = (rawUrl.split("?")[0] ?? "").trim();
|
|
102
|
+
const publicApiPaths = new Set(["/api/auth/login", "/api/auth/token", "/api/auth/status", "/api/auth/logout"]);
|
|
103
|
+
const publicPagePaths = new Set(["/login"]);
|
|
104
|
+
if (deps.authManager.isTokenBootstrapEnabled()) {
|
|
105
|
+
publicPagePaths.add("/auth/token");
|
|
106
|
+
}
|
|
107
|
+
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
|
108
|
+
done();
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const session = deps.authManager.getSessionFromRequest(request);
|
|
112
|
+
const requiresAuthForApi = pathname.startsWith("/api/") || pathname.startsWith("/workspaces/");
|
|
113
|
+
if (requiresAuthForApi && !session) {
|
|
114
|
+
// Allow OpenCode plugin -> CodeNomad calls with per-instance basic auth.
|
|
115
|
+
const pluginMatch = pathname.match(/^\/workspaces\/([^/]+)\/plugin(?:\/|$)/);
|
|
116
|
+
if (pluginMatch) {
|
|
117
|
+
const workspaceId = pluginMatch[1];
|
|
118
|
+
const expected = deps.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
|
|
119
|
+
const provided = Array.isArray(request.headers.authorization)
|
|
120
|
+
? request.headers.authorization[0]
|
|
121
|
+
: request.headers.authorization;
|
|
122
|
+
if (expected && provided && provided === expected) {
|
|
123
|
+
done();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
sendUnauthorized(request, reply);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!session && wantsHtml(request)) {
|
|
131
|
+
reply.redirect("/login");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
done();
|
|
135
|
+
});
|
|
136
|
+
app.get("/", async (request, reply) => {
|
|
137
|
+
const session = deps.authManager.getSessionFromRequest(request);
|
|
138
|
+
if (!session) {
|
|
139
|
+
reply.redirect("/login");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (deps.uiDevServerUrl) {
|
|
143
|
+
await proxyToDevServer(request, reply, deps.uiDevServerUrl);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const uiDir = deps.uiStaticDir;
|
|
147
|
+
const indexPath = path.join(uiDir, "index.html");
|
|
148
|
+
if (uiDir && fs.existsSync(indexPath)) {
|
|
149
|
+
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
reply.code(404).send({ message: "UI bundle missing" });
|
|
153
|
+
});
|
|
74
154
|
registerWorkspaceRoutes(app, { workspaceManager: deps.workspaceManager });
|
|
75
155
|
registerConfigRoutes(app, { configStore: deps.configStore, binaryRegistry: deps.binaryRegistry });
|
|
76
156
|
registerFilesystemRoutes(app, { fileSystemBrowser: deps.fileSystemBrowser });
|
|
@@ -85,10 +165,10 @@ export function createHttpServer(deps) {
|
|
|
85
165
|
registerBackgroundProcessRoutes(app, { backgroundProcessManager });
|
|
86
166
|
registerInstanceProxyRoutes(app, { workspaceManager: deps.workspaceManager, logger: proxyLogger });
|
|
87
167
|
if (deps.uiDevServerUrl) {
|
|
88
|
-
setupDevProxy(app, deps.uiDevServerUrl);
|
|
168
|
+
setupDevProxy(app, deps.uiDevServerUrl, deps.authManager);
|
|
89
169
|
}
|
|
90
170
|
else {
|
|
91
|
-
setupStaticUi(app, deps.uiStaticDir);
|
|
171
|
+
setupStaticUi(app, deps.uiStaticDir, deps.authManager);
|
|
92
172
|
}
|
|
93
173
|
return {
|
|
94
174
|
instance: app,
|
|
@@ -192,11 +272,18 @@ async function proxyWorkspaceRequest(args) {
|
|
|
192
272
|
const queryIndex = (request.raw.url ?? "").indexOf("?");
|
|
193
273
|
const search = queryIndex >= 0 ? (request.raw.url ?? "").slice(queryIndex) : "";
|
|
194
274
|
const targetUrl = `http://${INSTANCE_PROXY_HOST}:${port}${normalizedSuffix}${search}`;
|
|
275
|
+
const instanceAuthHeader = workspaceManager.getInstanceAuthorizationHeader(workspaceId);
|
|
195
276
|
logger.debug({ workspaceId, method: request.method, targetUrl }, "Proxying request to instance");
|
|
196
277
|
if (logger.isLevelEnabled("trace")) {
|
|
197
278
|
logger.trace({ workspaceId, targetUrl, body: request.body }, "Instance proxy payload");
|
|
198
279
|
}
|
|
199
280
|
return reply.from(targetUrl, {
|
|
281
|
+
rewriteRequestHeaders: (_originalRequest, headers) => {
|
|
282
|
+
if (instanceAuthHeader) {
|
|
283
|
+
headers.authorization = instanceAuthHeader;
|
|
284
|
+
}
|
|
285
|
+
return headers;
|
|
286
|
+
},
|
|
200
287
|
onError: (proxyReply, { error }) => {
|
|
201
288
|
logger.error({ err: error, workspaceId, targetUrl }, "Failed to proxy workspace request");
|
|
202
289
|
if (!proxyReply.sent) {
|
|
@@ -212,7 +299,7 @@ function normalizeInstanceSuffix(pathSuffix) {
|
|
|
212
299
|
const trimmed = pathSuffix.replace(/^\/+/, "");
|
|
213
300
|
return trimmed.length === 0 ? "/" : `/${trimmed}`;
|
|
214
301
|
}
|
|
215
|
-
function setupStaticUi(app, uiDir) {
|
|
302
|
+
function setupStaticUi(app, uiDir, authManager) {
|
|
216
303
|
if (!uiDir) {
|
|
217
304
|
app.log.warn("UI static directory not provided; API endpoints only");
|
|
218
305
|
return;
|
|
@@ -233,6 +320,11 @@ function setupStaticUi(app, uiDir) {
|
|
|
233
320
|
reply.code(404).send({ message: "Not Found" });
|
|
234
321
|
return;
|
|
235
322
|
}
|
|
323
|
+
const session = authManager.getSessionFromRequest(request);
|
|
324
|
+
if (!session && wantsHtml(request)) {
|
|
325
|
+
reply.redirect("/login");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
236
328
|
if (fs.existsSync(indexPath)) {
|
|
237
329
|
reply.type("text/html").send(fs.readFileSync(indexPath, "utf-8"));
|
|
238
330
|
}
|
|
@@ -241,7 +333,7 @@ function setupStaticUi(app, uiDir) {
|
|
|
241
333
|
}
|
|
242
334
|
});
|
|
243
335
|
}
|
|
244
|
-
function setupDevProxy(app, upstreamBase) {
|
|
336
|
+
function setupDevProxy(app, upstreamBase, authManager) {
|
|
245
337
|
app.log.info({ upstreamBase }, "Proxying UI requests to development server");
|
|
246
338
|
app.setNotFoundHandler((request, reply) => {
|
|
247
339
|
const url = request.raw.url ?? "";
|
|
@@ -249,6 +341,11 @@ function setupDevProxy(app, upstreamBase) {
|
|
|
249
341
|
reply.code(404).send({ message: "Not Found" });
|
|
250
342
|
return;
|
|
251
343
|
}
|
|
344
|
+
const session = authManager.getSessionFromRequest(request);
|
|
345
|
+
if (!session && wantsHtml(request)) {
|
|
346
|
+
reply.redirect("/login");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
252
349
|
void proxyToDevServer(request, reply, upstreamBase);
|
|
253
350
|
});
|
|
254
351
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>CodeNomad Login</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
|
10
|
+
background: #0b0b0f;
|
|
11
|
+
color: #fff;
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
height: 100vh;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
.card {
|
|
19
|
+
width: 420px;
|
|
20
|
+
max-width: calc(100vw - 32px);
|
|
21
|
+
background: #14141c;
|
|
22
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
23
|
+
border-radius: 14px;
|
|
24
|
+
padding: 24px;
|
|
25
|
+
}
|
|
26
|
+
h1 {
|
|
27
|
+
font-size: 18px;
|
|
28
|
+
margin: 0 0 12px;
|
|
29
|
+
}
|
|
30
|
+
p {
|
|
31
|
+
margin: 0 0 18px;
|
|
32
|
+
color: rgba(255, 255, 255, 0.7);
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
line-height: 1.4;
|
|
35
|
+
}
|
|
36
|
+
label {
|
|
37
|
+
display: block;
|
|
38
|
+
font-size: 12px;
|
|
39
|
+
margin: 10px 0 6px;
|
|
40
|
+
color: rgba(255, 255, 255, 0.75);
|
|
41
|
+
}
|
|
42
|
+
input {
|
|
43
|
+
width: 100%;
|
|
44
|
+
box-sizing: border-box;
|
|
45
|
+
padding: 10px 12px;
|
|
46
|
+
border-radius: 10px;
|
|
47
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
48
|
+
background: #0f0f16;
|
|
49
|
+
color: #fff;
|
|
50
|
+
}
|
|
51
|
+
button {
|
|
52
|
+
width: 100%;
|
|
53
|
+
margin-top: 14px;
|
|
54
|
+
padding: 10px 12px;
|
|
55
|
+
border-radius: 10px;
|
|
56
|
+
border: 0;
|
|
57
|
+
background: #4c6fff;
|
|
58
|
+
color: #fff;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
cursor: pointer;
|
|
61
|
+
}
|
|
62
|
+
.error {
|
|
63
|
+
margin-top: 12px;
|
|
64
|
+
color: #ff6b6b;
|
|
65
|
+
font-size: 13px;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
68
|
+
</head>
|
|
69
|
+
<body>
|
|
70
|
+
<div class="card">
|
|
71
|
+
<h1>Sign in</h1>
|
|
72
|
+
<p>This CodeNomad server is protected. Enter your credentials to continue.</p>
|
|
73
|
+
|
|
74
|
+
<label for="username">Username</label>
|
|
75
|
+
<input id="username" autocomplete="username" placeholder="{{DEFAULT_USERNAME}}" value="" />
|
|
76
|
+
|
|
77
|
+
<label for="password">Password</label>
|
|
78
|
+
<input id="password" type="password" autocomplete="current-password" value="" />
|
|
79
|
+
|
|
80
|
+
<button id="submit" type="button">Continue</button>
|
|
81
|
+
<div id="error" class="error" style="display: none"></div>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<script>
|
|
85
|
+
const $ = (id) => document.getElementById(id)
|
|
86
|
+
const errorEl = $("error")
|
|
87
|
+
const showError = (msg) => {
|
|
88
|
+
errorEl.textContent = msg
|
|
89
|
+
errorEl.style.display = "block"
|
|
90
|
+
}
|
|
91
|
+
const hideError = () => {
|
|
92
|
+
errorEl.textContent = ""
|
|
93
|
+
errorEl.style.display = "none"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function submit() {
|
|
97
|
+
hideError()
|
|
98
|
+
const username = $("username").value.trim()
|
|
99
|
+
const password = $("password").value
|
|
100
|
+
if (!username || !password) {
|
|
101
|
+
showError("Username and password are required.")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const res = await fetch("/api/auth/login", {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify({ username, password }),
|
|
109
|
+
credentials: "include",
|
|
110
|
+
})
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
let message = ""
|
|
113
|
+
try {
|
|
114
|
+
const json = await res.json()
|
|
115
|
+
message = json && json.error ? String(json.error) : ""
|
|
116
|
+
} catch {
|
|
117
|
+
message = ""
|
|
118
|
+
}
|
|
119
|
+
showError(message || `Login failed (${res.status})`)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
window.location.href = "/"
|
|
123
|
+
} catch (e) {
|
|
124
|
+
showError(e && e.message ? e.message : String(e))
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
$("submit").addEventListener("click", submit)
|
|
129
|
+
$("password").addEventListener("keydown", (e) => {
|
|
130
|
+
if (e.key === "Enter") submit()
|
|
131
|
+
})
|
|
132
|
+
</script>
|
|
133
|
+
</body>
|
|
134
|
+
</html>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>CodeNomad</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
|
10
|
+
background: #0b0b0f;
|
|
11
|
+
color: #fff;
|
|
12
|
+
display: flex;
|
|
13
|
+
align-items: center;
|
|
14
|
+
justify-content: center;
|
|
15
|
+
height: 100vh;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
18
|
+
.card {
|
|
19
|
+
width: 420px;
|
|
20
|
+
max-width: calc(100vw - 32px);
|
|
21
|
+
background: #14141c;
|
|
22
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
23
|
+
border-radius: 14px;
|
|
24
|
+
padding: 24px;
|
|
25
|
+
}
|
|
26
|
+
h1 {
|
|
27
|
+
font-size: 18px;
|
|
28
|
+
margin: 0 0 12px;
|
|
29
|
+
}
|
|
30
|
+
p {
|
|
31
|
+
margin: 0;
|
|
32
|
+
color: rgba(255, 255, 255, 0.7);
|
|
33
|
+
font-size: 13px;
|
|
34
|
+
line-height: 1.4;
|
|
35
|
+
}
|
|
36
|
+
.error {
|
|
37
|
+
margin-top: 12px;
|
|
38
|
+
color: #ff6b6b;
|
|
39
|
+
font-size: 13px;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<div class="card">
|
|
45
|
+
<h1>Connecting…</h1>
|
|
46
|
+
<p>Finalizing local authentication.</p>
|
|
47
|
+
<div id="error" class="error" style="display: none"></div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
const token = (location.hash || "").replace(/^#/, "").trim()
|
|
52
|
+
const errorEl = document.getElementById("error")
|
|
53
|
+
const showError = (msg) => {
|
|
54
|
+
errorEl.textContent = msg
|
|
55
|
+
errorEl.style.display = "block"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function run() {
|
|
59
|
+
if (!token) {
|
|
60
|
+
showError("Missing bootstrap token.")
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch("/api/auth/token", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ token }),
|
|
69
|
+
credentials: "include",
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
let message = ""
|
|
74
|
+
try {
|
|
75
|
+
const json = await res.json()
|
|
76
|
+
message = json && json.error ? String(json.error) : ""
|
|
77
|
+
} catch {
|
|
78
|
+
message = ""
|
|
79
|
+
}
|
|
80
|
+
showError(message || `Token exchange failed (${res.status})`)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
window.location.replace("/")
|
|
85
|
+
} catch (e) {
|
|
86
|
+
showError(e && e.message ? e.message : String(e))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
run()
|
|
91
|
+
</script>
|
|
92
|
+
</body>
|
|
93
|
+
</html>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { isLoopbackAddress } from "../../auth/http-auth";
|
|
4
|
+
const LoginSchema = z.object({
|
|
5
|
+
username: z.string().min(1),
|
|
6
|
+
password: z.string().min(1),
|
|
7
|
+
});
|
|
8
|
+
const TokenSchema = z.object({
|
|
9
|
+
token: z.string().min(1),
|
|
10
|
+
});
|
|
11
|
+
const PasswordSchema = z.object({
|
|
12
|
+
password: z.string().min(8),
|
|
13
|
+
});
|
|
14
|
+
const LOGIN_TEMPLATE_URL = new URL("./auth-pages/login.html", import.meta.url);
|
|
15
|
+
const TOKEN_TEMPLATE_URL = new URL("./auth-pages/token.html", import.meta.url);
|
|
16
|
+
let cachedLoginTemplate = null;
|
|
17
|
+
let cachedTokenTemplate = null;
|
|
18
|
+
function readTemplate(url, cache) {
|
|
19
|
+
if (cache)
|
|
20
|
+
return cache;
|
|
21
|
+
const content = fs.readFileSync(url, "utf-8");
|
|
22
|
+
return content;
|
|
23
|
+
}
|
|
24
|
+
function getLoginHtml(defaultUsername) {
|
|
25
|
+
if (!cachedLoginTemplate) {
|
|
26
|
+
cachedLoginTemplate = readTemplate(LOGIN_TEMPLATE_URL, null);
|
|
27
|
+
}
|
|
28
|
+
const escapedUsername = escapeHtml(defaultUsername);
|
|
29
|
+
return cachedLoginTemplate.replace(/\{\{DEFAULT_USERNAME\}\}/g, escapedUsername);
|
|
30
|
+
}
|
|
31
|
+
function getTokenHtml() {
|
|
32
|
+
if (!cachedTokenTemplate) {
|
|
33
|
+
cachedTokenTemplate = readTemplate(TOKEN_TEMPLATE_URL, null);
|
|
34
|
+
}
|
|
35
|
+
return cachedTokenTemplate;
|
|
36
|
+
}
|
|
37
|
+
export function registerAuthRoutes(app, deps) {
|
|
38
|
+
app.get("/login", async (_request, reply) => {
|
|
39
|
+
const status = deps.authManager.getStatus();
|
|
40
|
+
reply.type("text/html").send(getLoginHtml(status.username));
|
|
41
|
+
});
|
|
42
|
+
app.get("/auth/token", async (request, reply) => {
|
|
43
|
+
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
|
44
|
+
reply.code(404).send({ error: "Not found" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
48
|
+
reply.code(404).send({ error: "Not found" });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
reply.type("text/html").send(getTokenHtml());
|
|
52
|
+
});
|
|
53
|
+
app.get("/api/auth/status", async (request, reply) => {
|
|
54
|
+
const session = deps.authManager.getSessionFromRequest(request);
|
|
55
|
+
if (!session) {
|
|
56
|
+
reply.send({ authenticated: false });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
reply.send({ authenticated: true, ...deps.authManager.getStatus() });
|
|
60
|
+
});
|
|
61
|
+
app.post("/api/auth/login", async (request, reply) => {
|
|
62
|
+
const body = LoginSchema.parse(request.body ?? {});
|
|
63
|
+
const ok = deps.authManager.validateLogin(body.username, body.password);
|
|
64
|
+
if (!ok) {
|
|
65
|
+
reply.code(401).send({ error: "Invalid credentials" });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const session = deps.authManager.createSession(body.username);
|
|
69
|
+
deps.authManager.setSessionCookie(reply, session.id);
|
|
70
|
+
reply.send({ ok: true });
|
|
71
|
+
});
|
|
72
|
+
app.post("/api/auth/token", async (request, reply) => {
|
|
73
|
+
if (!deps.authManager.isTokenBootstrapEnabled()) {
|
|
74
|
+
reply.code(404).send({ error: "Not found" });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
|
78
|
+
reply.code(404).send({ error: "Not found" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const body = TokenSchema.parse(request.body ?? {});
|
|
82
|
+
const ok = deps.authManager.consumeBootstrapToken(body.token);
|
|
83
|
+
if (!ok) {
|
|
84
|
+
reply.code(401).send({ error: "Invalid token" });
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const username = deps.authManager.getStatus().username;
|
|
88
|
+
const session = deps.authManager.createSession(username);
|
|
89
|
+
deps.authManager.setSessionCookie(reply, session.id);
|
|
90
|
+
reply.send({ ok: true });
|
|
91
|
+
});
|
|
92
|
+
app.post("/api/auth/logout", async (_request, reply) => {
|
|
93
|
+
deps.authManager.clearSessionCookie(reply);
|
|
94
|
+
reply.send({ ok: true });
|
|
95
|
+
});
|
|
96
|
+
app.post("/api/auth/password", async (request, reply) => {
|
|
97
|
+
const session = deps.authManager.getSessionFromRequest(request);
|
|
98
|
+
if (!session) {
|
|
99
|
+
reply.code(401).send({ error: "Unauthorized" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const body = PasswordSchema.parse(request.body ?? {});
|
|
103
|
+
try {
|
|
104
|
+
const status = deps.authManager.setPassword(body.password);
|
|
105
|
+
reply.send({ ok: true, ...status });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
109
|
+
reply.code(409).type("text/plain").send(message);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function escapeHtml(value) {
|
|
114
|
+
return value.replace(/[&<>"]/g, (char) => {
|
|
115
|
+
switch (char) {
|
|
116
|
+
case "&":
|
|
117
|
+
return "&";
|
|
118
|
+
case "<":
|
|
119
|
+
return "<";
|
|
120
|
+
case ">":
|
|
121
|
+
return ">";
|
|
122
|
+
case '"':
|
|
123
|
+
return """;
|
|
124
|
+
default:
|
|
125
|
+
return char;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -71,8 +71,13 @@ export class InstanceEventBridge {
|
|
|
71
71
|
}
|
|
72
72
|
async consumeStream(workspaceId, port, signal) {
|
|
73
73
|
const url = `http://${INSTANCE_HOST}:${port}/event`;
|
|
74
|
+
const headers = { Accept: "text/event-stream" };
|
|
75
|
+
const authHeader = this.options.workspaceManager.getInstanceAuthorizationHeader(workspaceId);
|
|
76
|
+
if (authHeader) {
|
|
77
|
+
headers["Authorization"] = authHeader;
|
|
78
|
+
}
|
|
74
79
|
const response = await fetch(url, {
|
|
75
|
-
headers
|
|
80
|
+
headers,
|
|
76
81
|
signal,
|
|
77
82
|
dispatcher: STREAM_AGENT,
|
|
78
83
|
});
|