kanna-code 0.33.0 → 0.33.2
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/README.md +1 -1
- package/dist/client/assets/{index-BYzAMpzV.css → index-FQ1dOuTJ.css} +1 -1
- package/dist/client/assets/{index-ab3JkqZ2.js → index-LriUBeIQ.js} +20 -20
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/auth.test.ts +21 -31
- package/src/server/auth.ts +7 -142
- package/src/server/server.ts +3 -3
package/dist/client/index.html
CHANGED
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
})()
|
|
31
31
|
</script>
|
|
32
32
|
<title>Kanna</title>
|
|
33
|
-
<script type="module" crossorigin src="/assets/index-
|
|
34
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
33
|
+
<script type="module" crossorigin src="/assets/index-LriUBeIQ.js"></script>
|
|
34
|
+
<link rel="stylesheet" crossorigin href="/assets/index-FQ1dOuTJ.css">
|
|
35
35
|
</head>
|
|
36
36
|
<body>
|
|
37
37
|
<div id="root"></div>
|
package/package.json
CHANGED
package/src/server/auth.test.ts
CHANGED
|
@@ -31,13 +31,14 @@ function extractCookie(response: Response) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
describe("password auth", () => {
|
|
34
|
-
test("
|
|
34
|
+
test("serves the app shell to unauthenticated browser requests", async () => {
|
|
35
35
|
const { server } = await startPasswordServer()
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
const response = await fetch(`http://localhost:${server.port}
|
|
39
|
-
expect(response.status).toBe(
|
|
40
|
-
expect(response.headers.get("
|
|
38
|
+
const response = await fetch(`http://localhost:${server.port}/chat/demo`, { headers: { Accept: "text/html" } })
|
|
39
|
+
expect(response.status).toBe(200)
|
|
40
|
+
expect(response.headers.get("content-type")).toContain("text/html")
|
|
41
|
+
expect(await response.text()).toContain('id="root"')
|
|
41
42
|
} finally {
|
|
42
43
|
await server.stop()
|
|
43
44
|
}
|
|
@@ -54,14 +55,13 @@ describe("password auth", () => {
|
|
|
54
55
|
}
|
|
55
56
|
})
|
|
56
57
|
|
|
57
|
-
test("
|
|
58
|
+
test("redirects /auth/login back into the app", async () => {
|
|
58
59
|
const { server } = await startPasswordServer()
|
|
59
60
|
|
|
60
61
|
try {
|
|
61
|
-
const response = await fetch(`http://localhost:${server.port}/auth/login
|
|
62
|
-
expect(response.status).toBe(
|
|
63
|
-
expect(response.headers.get("
|
|
64
|
-
expect(await response.text()).toContain("This server is password protected")
|
|
62
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2Fchat%2Fdemo`, { redirect: "manual" })
|
|
63
|
+
expect(response.status).toBe(302)
|
|
64
|
+
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/chat/demo`)
|
|
65
65
|
} finally {
|
|
66
66
|
await server.stop()
|
|
67
67
|
}
|
|
@@ -71,20 +71,16 @@ describe("password auth", () => {
|
|
|
71
71
|
const { server } = await startPasswordServer()
|
|
72
72
|
|
|
73
73
|
try {
|
|
74
|
-
const formData = new FormData()
|
|
75
|
-
formData.append("password", "secret")
|
|
76
|
-
formData.append("next", "/")
|
|
77
74
|
const response = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
78
75
|
method: "POST",
|
|
79
|
-
body:
|
|
80
|
-
redirect: "manual",
|
|
76
|
+
body: JSON.stringify({ password: "secret", next: "/" }),
|
|
81
77
|
headers: {
|
|
78
|
+
"Content-Type": "application/json",
|
|
82
79
|
Origin: `http://localhost:${server.port}`,
|
|
83
80
|
},
|
|
84
81
|
})
|
|
85
82
|
|
|
86
|
-
expect(response.status).toBe(
|
|
87
|
-
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
|
|
83
|
+
expect(response.status).toBe(200)
|
|
88
84
|
expect(extractCookie(response)).toContain("kanna_session=")
|
|
89
85
|
} finally {
|
|
90
86
|
await server.stop()
|
|
@@ -95,19 +91,16 @@ describe("password auth", () => {
|
|
|
95
91
|
const { server } = await startPasswordServer()
|
|
96
92
|
|
|
97
93
|
try {
|
|
98
|
-
const formData = new FormData()
|
|
99
|
-
formData.append("password", "wrong")
|
|
100
94
|
const response = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
101
95
|
method: "POST",
|
|
102
|
-
body:
|
|
103
|
-
redirect: "manual",
|
|
96
|
+
body: JSON.stringify({ password: "wrong", next: "/" }),
|
|
104
97
|
headers: {
|
|
98
|
+
"Content-Type": "application/json",
|
|
105
99
|
Origin: `http://localhost:${server.port}`,
|
|
106
100
|
},
|
|
107
101
|
})
|
|
108
102
|
|
|
109
|
-
expect(response.status).toBe(
|
|
110
|
-
expect(response.headers.get("location")).toContain("/auth/login?error=1")
|
|
103
|
+
expect(response.status).toBe(401)
|
|
111
104
|
expect(response.headers.get("set-cookie")).toBeNull()
|
|
112
105
|
} finally {
|
|
113
106
|
await server.stop()
|
|
@@ -178,15 +171,14 @@ describe("password auth", () => {
|
|
|
178
171
|
const { server } = await startPasswordServer({ port: 54321 })
|
|
179
172
|
|
|
180
173
|
try {
|
|
181
|
-
const response = await fetch(`http://localhost:${server.port}
|
|
174
|
+
const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
|
|
182
175
|
redirect: "manual",
|
|
183
176
|
headers: {
|
|
184
|
-
Accept: "text/html",
|
|
185
177
|
"X-Forwarded-Proto": "https",
|
|
186
178
|
},
|
|
187
179
|
})
|
|
188
180
|
expect(response.status).toBe(302)
|
|
189
|
-
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}
|
|
181
|
+
expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
|
|
190
182
|
|
|
191
183
|
const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
192
184
|
method: "POST",
|
|
@@ -220,15 +212,14 @@ describe("password auth", () => {
|
|
|
220
212
|
const { server } = await startPasswordServer({ port: 54322, trustProxy: true })
|
|
221
213
|
|
|
222
214
|
try {
|
|
223
|
-
const redirect = await fetch(`http://localhost:${server.port}
|
|
215
|
+
const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
|
|
224
216
|
redirect: "manual",
|
|
225
217
|
headers: {
|
|
226
|
-
Accept: "text/html",
|
|
227
218
|
"X-Forwarded-Proto": "https",
|
|
228
219
|
},
|
|
229
220
|
})
|
|
230
221
|
expect(redirect.status).toBe(302)
|
|
231
|
-
expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}
|
|
222
|
+
expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}/`)
|
|
232
223
|
|
|
233
224
|
const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
234
225
|
method: "POST",
|
|
@@ -260,15 +251,14 @@ describe("password auth", () => {
|
|
|
260
251
|
const { server } = await startPasswordServer({ port: 54323, trustProxy: true })
|
|
261
252
|
|
|
262
253
|
try {
|
|
263
|
-
const redirect = await fetch(`http://localhost:${server.port}
|
|
254
|
+
const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
|
|
264
255
|
redirect: "manual",
|
|
265
256
|
headers: {
|
|
266
|
-
Accept: "text/html",
|
|
267
257
|
"X-Forwarded-Proto": "ftp",
|
|
268
258
|
},
|
|
269
259
|
})
|
|
270
260
|
expect(redirect.status).toBe(302)
|
|
271
|
-
expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}
|
|
261
|
+
expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}/`)
|
|
272
262
|
|
|
273
263
|
const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
|
|
274
264
|
method: "POST",
|
package/src/server/auth.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
|
-
import { APP_NAME } from "../shared/branding"
|
|
3
2
|
|
|
4
3
|
const SESSION_COOKIE_NAME = "kanna_session"
|
|
5
4
|
|
|
@@ -9,17 +8,12 @@ export interface AuthStatusPayload {
|
|
|
9
8
|
}
|
|
10
9
|
|
|
11
10
|
export interface AuthManager {
|
|
12
|
-
readonly enabled: true
|
|
13
11
|
isAuthenticated(req: Request): boolean
|
|
14
12
|
validateOrigin(req: Request): boolean
|
|
15
|
-
|
|
16
|
-
clearSessionCookie(req: Request): string
|
|
17
|
-
verifyPassword(candidate: string): boolean
|
|
13
|
+
redirectToApp(req: Request): Response
|
|
18
14
|
handleLogin(req: Request, nextPath: string): Promise<Response>
|
|
19
15
|
handleLogout(req: Request): Response
|
|
20
16
|
handleStatus(req: Request): Response
|
|
21
|
-
renderLoginPage(req: Request): Response
|
|
22
|
-
unauthorizedResponse(req: Request): Response
|
|
23
17
|
}
|
|
24
18
|
|
|
25
19
|
function parseCookies(header: string | null) {
|
|
@@ -94,7 +88,6 @@ async function readLoginForm(req: Request) {
|
|
|
94
88
|
return {
|
|
95
89
|
password: typeof payload.password === "string" ? payload.password : "",
|
|
96
90
|
nextPath: sanitizeNextPath(typeof payload.next === "string" ? payload.next : "/"),
|
|
97
|
-
wantsJson: true,
|
|
98
91
|
}
|
|
99
92
|
}
|
|
100
93
|
|
|
@@ -102,24 +95,9 @@ async function readLoginForm(req: Request) {
|
|
|
102
95
|
return {
|
|
103
96
|
password: String(formData.get("password") ?? ""),
|
|
104
97
|
nextPath: sanitizeNextPath(String(formData.get("next") ?? "/")),
|
|
105
|
-
wantsJson: false,
|
|
106
98
|
}
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
function requestWantsHtml(req: Request) {
|
|
110
|
-
const accept = req.headers.get("accept") ?? ""
|
|
111
|
-
return accept.includes("text/html")
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function escapeHtml(value: string) {
|
|
115
|
-
return value
|
|
116
|
-
.replaceAll("&", "&")
|
|
117
|
-
.replaceAll("<", "<")
|
|
118
|
-
.replaceAll(">", ">")
|
|
119
|
-
.replaceAll("\"", """)
|
|
120
|
-
.replaceAll("'", "'")
|
|
121
|
-
}
|
|
122
|
-
|
|
123
101
|
export interface AuthManagerOptions {
|
|
124
102
|
/**
|
|
125
103
|
* When true, the auth layer trusts X-Forwarded-Proto to decide whether the
|
|
@@ -184,108 +162,9 @@ export function createAuthManager(password: string, options: AuthManagerOptions
|
|
|
184
162
|
} satisfies AuthStatusPayload)
|
|
185
163
|
}
|
|
186
164
|
|
|
187
|
-
function
|
|
188
|
-
if (req.method === "GET" && requestWantsHtml(req)) {
|
|
189
|
-
const url = new URL(req.url)
|
|
190
|
-
const loginUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
|
|
191
|
-
loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
|
|
192
|
-
return Response.redirect(loginUrl, 302)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function renderLoginPage(req: Request) {
|
|
199
|
-
if (isAuthenticated(req)) {
|
|
200
|
-
const currentUrl = new URL(req.url)
|
|
201
|
-
return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
|
|
202
|
-
}
|
|
203
|
-
|
|
165
|
+
function redirectToApp(req: Request) {
|
|
204
166
|
const currentUrl = new URL(req.url)
|
|
205
|
-
|
|
206
|
-
const showError = currentUrl.searchParams.get("error") === "1"
|
|
207
|
-
const html = `<!doctype html>
|
|
208
|
-
<html lang="en">
|
|
209
|
-
<head>
|
|
210
|
-
<meta charset="utf-8" />
|
|
211
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
212
|
-
<title>${escapeHtml(APP_NAME)} Login</title>
|
|
213
|
-
<style>
|
|
214
|
-
:root { color-scheme: dark; }
|
|
215
|
-
* { box-sizing: border-box; }
|
|
216
|
-
body {
|
|
217
|
-
margin: 0;
|
|
218
|
-
min-height: 100vh;
|
|
219
|
-
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
220
|
-
background:
|
|
221
|
-
radial-gradient(circle at top, rgba(255,255,255,0.10), transparent 28%),
|
|
222
|
-
linear-gradient(160deg, #16181d 0%, #0b0d10 55%, #050608 100%);
|
|
223
|
-
color: #f4f7fb;
|
|
224
|
-
display: grid;
|
|
225
|
-
place-items: center;
|
|
226
|
-
padding: 24px;
|
|
227
|
-
}
|
|
228
|
-
.card {
|
|
229
|
-
width: min(420px, 100%);
|
|
230
|
-
border: 1px solid rgba(255,255,255,0.12);
|
|
231
|
-
background: rgba(12, 15, 20, 0.88);
|
|
232
|
-
backdrop-filter: blur(10px);
|
|
233
|
-
border-radius: 24px;
|
|
234
|
-
padding: 28px;
|
|
235
|
-
box-shadow: 0 24px 70px rgba(0,0,0,0.35);
|
|
236
|
-
}
|
|
237
|
-
h1 { margin: 0 0 8px; font-size: 28px; }
|
|
238
|
-
p { margin: 0 0 20px; color: #a6b0bd; line-height: 1.5; }
|
|
239
|
-
label { display: block; font-size: 13px; margin-bottom: 8px; color: #d7dce4; }
|
|
240
|
-
input {
|
|
241
|
-
width: 100%;
|
|
242
|
-
border-radius: 14px;
|
|
243
|
-
border: 1px solid rgba(255,255,255,0.16);
|
|
244
|
-
background: rgba(255,255,255,0.04);
|
|
245
|
-
color: inherit;
|
|
246
|
-
padding: 14px 16px;
|
|
247
|
-
font-size: 15px;
|
|
248
|
-
margin-bottom: 16px;
|
|
249
|
-
}
|
|
250
|
-
button {
|
|
251
|
-
width: 100%;
|
|
252
|
-
border: 0;
|
|
253
|
-
border-radius: 14px;
|
|
254
|
-
padding: 14px 16px;
|
|
255
|
-
background: linear-gradient(135deg, #f4ede0 0%, #d9c4a1 100%);
|
|
256
|
-
color: #16181d;
|
|
257
|
-
font-size: 15px;
|
|
258
|
-
font-weight: 700;
|
|
259
|
-
cursor: pointer;
|
|
260
|
-
}
|
|
261
|
-
.error {
|
|
262
|
-
margin-bottom: 16px;
|
|
263
|
-
border-radius: 14px;
|
|
264
|
-
background: rgba(255, 106, 106, 0.12);
|
|
265
|
-
border: 1px solid rgba(255, 106, 106, 0.24);
|
|
266
|
-
color: #ffb8b8;
|
|
267
|
-
padding: 12px 14px;
|
|
268
|
-
font-size: 14px;
|
|
269
|
-
}
|
|
270
|
-
</style>
|
|
271
|
-
</head>
|
|
272
|
-
<body>
|
|
273
|
-
<form class="card" method="post" action="/auth/login">
|
|
274
|
-
<h1>${escapeHtml(APP_NAME)}</h1>
|
|
275
|
-
<p>This server is password protected. Enter the launch password to continue.</p>
|
|
276
|
-
${showError ? '<div class="error">Incorrect password. Try again.</div>' : ""}
|
|
277
|
-
<input type="hidden" name="next" value="${escapeHtml(nextPath)}" />
|
|
278
|
-
<label for="password">Password</label>
|
|
279
|
-
<input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
|
|
280
|
-
<button type="submit">Unlock</button>
|
|
281
|
-
</form>
|
|
282
|
-
</body>
|
|
283
|
-
</html>`
|
|
284
|
-
return new Response(html, {
|
|
285
|
-
headers: {
|
|
286
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
287
|
-
},
|
|
288
|
-
})
|
|
167
|
+
return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
|
|
289
168
|
}
|
|
290
169
|
|
|
291
170
|
async function handleLogin(req: Request, fallbackNextPath: string) {
|
|
@@ -293,21 +172,12 @@ export function createAuthManager(password: string, options: AuthManagerOptions
|
|
|
293
172
|
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
294
173
|
}
|
|
295
174
|
|
|
296
|
-
const { password: candidate, nextPath
|
|
175
|
+
const { password: candidate, nextPath } = await readLoginForm(req)
|
|
297
176
|
if (!verifyPassword(candidate)) {
|
|
298
|
-
|
|
299
|
-
return Response.json({ error: "Invalid password" }, { status: 401 })
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const redirectUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
|
|
303
|
-
redirectUrl.searchParams.set("error", "1")
|
|
304
|
-
redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
|
|
305
|
-
return Response.redirect(redirectUrl, 302)
|
|
177
|
+
return Response.json({ error: "Invalid password" }, { status: 401 })
|
|
306
178
|
}
|
|
307
179
|
|
|
308
|
-
const response =
|
|
309
|
-
? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
|
|
310
|
-
: Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), effectiveOrigin(req, trustProxy)), 302)
|
|
180
|
+
const response = Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
|
|
311
181
|
|
|
312
182
|
response.headers.set("Set-Cookie", createSessionCookie(req))
|
|
313
183
|
return response
|
|
@@ -324,16 +194,11 @@ export function createAuthManager(password: string, options: AuthManagerOptions
|
|
|
324
194
|
}
|
|
325
195
|
|
|
326
196
|
return {
|
|
327
|
-
enabled: true,
|
|
328
197
|
isAuthenticated,
|
|
329
198
|
validateOrigin,
|
|
330
|
-
|
|
331
|
-
clearSessionCookie,
|
|
332
|
-
verifyPassword,
|
|
199
|
+
redirectToApp,
|
|
333
200
|
handleLogin,
|
|
334
201
|
handleLogout,
|
|
335
202
|
handleStatus,
|
|
336
|
-
renderLoginPage,
|
|
337
|
-
unauthorizedResponse,
|
|
338
203
|
}
|
|
339
204
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -175,7 +175,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
175
175
|
if (auth) {
|
|
176
176
|
if (url.pathname === "/auth/login") {
|
|
177
177
|
if (req.method === "GET") {
|
|
178
|
-
return auth.
|
|
178
|
+
return auth.redirectToApp(req)
|
|
179
179
|
}
|
|
180
180
|
if (req.method === "POST") {
|
|
181
181
|
return auth.handleLogin(req, "/")
|
|
@@ -190,8 +190,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
|
|
|
190
190
|
if (!auth.isAuthenticated(req)) {
|
|
191
191
|
return new Response("Unauthorized", { status: 401 })
|
|
192
192
|
}
|
|
193
|
-
} else if (!auth.isAuthenticated(req)) {
|
|
194
|
-
return
|
|
193
|
+
} else if ((url.pathname === "/health" || url.pathname.startsWith("/api/")) && !auth.isAuthenticated(req)) {
|
|
194
|
+
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|