@cuongtran001/kanna 1.71.2 → 1.72.0
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/client/assets/{BoardsPage-DudYbDyu.js → BoardsPage-Cv8FIzb-.js} +1 -1
- package/dist/client/assets/{BoardsRoutePage-DUvJAyzh.js → BoardsRoutePage-D2B4CKzS.js} +1 -1
- package/dist/client/assets/{ChatPage-ChFKw7nK.js → ChatPage-DyVlp7tq.js} +1 -1
- package/dist/client/assets/{CronJobRow-DU1RtrCs.js → CronJobRow-DtlnNrFa.js} +1 -1
- package/dist/client/assets/{CronJobsPage-cjH7p3lh.js → CronJobsPage-BqBSaj2f.js} +1 -1
- package/dist/client/assets/{FilePreviewSheet-wcpWIb90.js → FilePreviewSheet-cAupHL1-.js} +1 -1
- package/dist/client/assets/{FlowSurface-Doyyct0h.js → FlowSurface-Cy6Ypvp4.js} +1 -1
- package/dist/client/assets/{GenUIBlock-D-TBN4Um.js → GenUIBlock-DS3aMR8o.js} +2 -2
- package/dist/client/assets/{KannaSocketProvider-uWU6WkNo.js → KannaSocketProvider-0GNmyg3O.js} +1 -1
- package/dist/client/assets/{LocalProjectsPage-D8V8a3WM.js → LocalProjectsPage-DQQtJlM1.js} +1 -1
- package/dist/client/assets/{SettingsPage-CiCU3nka.js → SettingsPage-X5P3EyCv.js} +1 -1
- package/dist/client/assets/{StackBoardsRoutePage-DDvdmA3Y.js → StackBoardsRoutePage-Dq3kQSm2.js} +1 -1
- package/dist/client/assets/{ToolCallMessage-CHJSiauG.js → ToolCallMessage-Dhc2sf0Y.js} +1 -1
- package/dist/client/assets/{VChartSurface-DVRoAi5e.js → VChartSurface-CWzLshgk.js} +1 -1
- package/dist/client/assets/{WorkflowsPage-LwL034ZP.js → WorkflowsPage-BW0zI7T4.js} +1 -1
- package/dist/client/assets/{WorkflowsSection-HlGaR0pJ.js → WorkflowsSection-yOlBEJiD.js} +1 -1
- package/dist/client/assets/{chart-renderer-BZXJQWtw.js → chart-renderer-CARfWmNI.js} +1 -1
- package/dist/client/assets/{chatSounds-BNvUSPAi.js → chatSounds-BgTY66uu.js} +1 -1
- package/dist/client/assets/chevron-left-CFGYD17H.js +1 -0
- package/dist/client/assets/{clipboard.adapter-CIRv1YNb.js → clipboard.adapter-DpHeqwMw.js} +1 -1
- package/dist/client/assets/{columnStyle-mS8DKLCV.js → columnStyle-DYXYcPnt.js} +1 -1
- package/dist/client/assets/{copyFeedbackStore-DHAfKV4i.js → copyFeedbackStore-DZw_cFtR.js} +1 -1
- package/dist/client/assets/{createScopedStore-or82_bxr.js → createScopedStore-BVEubecu.js} +1 -1
- package/dist/client/assets/{dist-Cvh6FEf5.js → dist-B_c3yYUS.js} +1 -1
- package/dist/client/assets/{ellipsis-CWiRFysC.js → ellipsis-63_6PNeI.js} +1 -1
- package/dist/client/assets/{flow-kinds-Digyo6AB.js → flow-kinds-CG9-P5sO.js} +1 -1
- package/dist/client/assets/{folder-CnSb5m9v.js → folder-uf9sKjmp.js} +1 -1
- package/dist/client/assets/{formatDuration-DsrNkArK.js → formatDuration-DXQnrqg6.js} +1 -1
- package/dist/client/assets/{genui-BagNQVNI.js → genui-D649Dv92.js} +1 -1
- package/dist/client/assets/{globe-tc_9UiU3.js → globe-DRxtkxRw.js} +1 -1
- package/dist/client/assets/{host-C7I7nFuH.js → host-BpGUm8Ks.js} +1 -1
- package/dist/client/assets/{index-D9OYZjb7.js → index-BIXIaFOx.js} +3 -3
- package/dist/client/assets/{info-CgLDBnLR.js → info-CzLBGkjs.js} +1 -1
- package/dist/client/assets/{input-BaAZ4OHy.js → input-Dj2AfDyu.js} +1 -1
- package/dist/client/assets/{lexicalToReact-ClgTGbzM.js → lexicalToReact-DTdGRuI2.js} +3 -3
- package/dist/client/assets/{log-Ch_SNBXH.js → log-C-CyaVcQ.js} +1 -1
- package/dist/client/assets/{monitor-fFRMWd1S.js → monitor-Cl6GABTi.js} +1 -1
- package/dist/client/assets/{pendingActionsStore-CWNaCyjS.js → pendingActionsStore-CpbHd9ei.js} +1 -1
- package/dist/client/assets/{play-DD0bVaYg.js → play-cuJ4aGHN.js} +1 -1
- package/dist/client/assets/{plus-By3sppx5.js → plus-DpSRUvXD.js} +1 -1
- package/dist/client/assets/{popover-CJNN95M0.js → popover-C_I8IOWx.js} +1 -1
- package/dist/client/assets/{pushClient-DbwI0iz1.js → pushClient-CtuTxUvx.js} +1 -1
- package/dist/client/assets/runDetached-QSRrHU3F.js +1 -0
- package/dist/client/assets/{segmented-control-DYmNRyQy.js → segmented-control-QukGPBuR.js} +1 -1
- package/dist/client/assets/{select-Dam9e0mi.js → select-BgMpt5jO.js} +1 -1
- package/dist/client/assets/{settings-header-button-DaCbsV31.js → settings-header-button-BLAONCyF.js} +1 -1
- package/dist/client/assets/{sidebarPendingActions-DxI6ubAL.js → sidebarPendingActions-Bde5y5HH.js} +1 -1
- package/dist/client/assets/{square-pen-DKZvXh3R.js → square-pen-CIU74x0s.js} +1 -1
- package/dist/client/assets/{state-mark-DxzVQsVa.js → state-mark-DS7GjjiN.js} +1 -1
- package/dist/client/assets/{status-pill-BFLy7eN9.js → status-pill-Bm1d4Yt2.js} +1 -1
- package/dist/client/assets/{terminal-DRYQ5avJ.js → terminal-D4dPFOU3.js} +1 -1
- package/dist/client/assets/{timer.adapter-BDh0KX9G.js → timer.adapter-5aM9hrUB.js} +1 -1
- package/dist/client/assets/{tools-toomKvSw.js → tools-5kr-voMM.js} +1 -1
- package/dist/client/assets/{triangle-alert-CM5uh-kq.js → triangle-alert-BAPOUptD.js} +1 -1
- package/dist/client/assets/{useAppGlobalState-BCUZEZEi.js → useAppGlobalState-BNrYZSMc.js} +1 -1
- package/dist/client/assets/{useKannaState-BvJAZTpL.js → useKannaState-DiqCTk6A.js} +1 -1
- package/dist/client/assets/{useTheme-B6cYOPpe.js → useTheme-DOxNN3XT.js} +1 -1
- package/dist/client/index.html +33 -33
- package/package.json +2 -1
- package/src/server/auth-backend.ts +16 -0
- package/src/server/auth.test.ts +62 -1
- package/src/server/auth.ts +90 -87
- package/src/server/better-auth.adapter.ts +197 -0
- package/src/server/http-dispatcher.ts +5 -5
- package/src/server/server.ts +7 -8
- package/dist/client/assets/chevron-left-FnMbL2tA.js +0 -1
- package/dist/client/assets/runDetached-CQOu05LK.js +0 -1
- package/src/server/auth-session-store.adapter.ts +0 -224
- package/src/server/auth-session-store.test.ts +0 -94
package/src/server/auth.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
2
|
-
import type { AuthSessionStore } from "./auth-session-store.adapter"
|
|
3
1
|
import { isJsonObject, type JsonValue } from "../shared/json"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
import {
|
|
3
|
+
CLIENT_IP_HEADER,
|
|
4
|
+
OWNER_EMAIL,
|
|
5
|
+
SESSION_COOKIE_NAME,
|
|
6
|
+
SIGN_IN_PATH,
|
|
7
|
+
type AuthBackend,
|
|
8
|
+
} from "./auth-backend"
|
|
7
9
|
|
|
8
10
|
export interface AuthStatusPayload {
|
|
9
11
|
enabled: boolean
|
|
@@ -11,12 +13,12 @@ export interface AuthStatusPayload {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export interface AuthManager {
|
|
14
|
-
isAuthenticated(req: Request): boolean
|
|
16
|
+
isAuthenticated(req: Request): Promise<boolean>
|
|
15
17
|
validateOrigin(req: Request): boolean
|
|
16
18
|
redirectToApp(req: Request): Response
|
|
17
|
-
handleLogin(req: Request, nextPath: string): Promise<Response>
|
|
18
|
-
handleLogout(req: Request): Response
|
|
19
|
-
handleStatus(req: Request): Response
|
|
19
|
+
handleLogin(req: Request, nextPath: string, clientIp: string | null): Promise<Response>
|
|
20
|
+
handleLogout(req: Request): Promise<Response>
|
|
21
|
+
handleStatus(req: Request): Promise<Response>
|
|
20
22
|
dispose(): Promise<void>
|
|
21
23
|
}
|
|
22
24
|
|
|
@@ -68,26 +70,19 @@ function shouldUseSecureCookie(req: Request, trustProxy: boolean) {
|
|
|
68
70
|
return new URL(req.url).protocol === "https:"
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const parts = [
|
|
73
|
+
function isLoopbackAddress(ip: string | null) {
|
|
74
|
+
if (!ip) return false
|
|
75
|
+
return ip === "::1" || ip.startsWith("127.") || ip.startsWith("::ffff:127.")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildCookie(name: string, value: string, maxAgeSeconds: number) {
|
|
79
|
+
return [
|
|
79
80
|
`${name}=${encodeURIComponent(value)}`,
|
|
80
81
|
"Path=/",
|
|
81
82
|
"HttpOnly",
|
|
82
83
|
"SameSite=Strict",
|
|
83
84
|
`Max-Age=${maxAgeSeconds}`,
|
|
84
|
-
]
|
|
85
|
-
|
|
86
|
-
if (shouldUseSecureCookie(req, trustProxy)) {
|
|
87
|
-
parts.push("Secure")
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return parts.join("; ")
|
|
85
|
+
].join("; ")
|
|
91
86
|
}
|
|
92
87
|
|
|
93
88
|
async function readLoginForm(req: Request) {
|
|
@@ -112,8 +107,6 @@ async function readLoginForm(req: Request) {
|
|
|
112
107
|
|
|
113
108
|
export interface AuthManagerOptions {
|
|
114
109
|
trustProxy?: boolean
|
|
115
|
-
sessionStore: AuthSessionStore
|
|
116
|
-
getMaxAgeMs: () => number
|
|
117
110
|
}
|
|
118
111
|
|
|
119
112
|
export function isPublicSharePath(url: string): boolean {
|
|
@@ -129,35 +122,38 @@ export function isPublicSharePath(url: string): boolean {
|
|
|
129
122
|
|| pathname.startsWith("/assets/share-view/")
|
|
130
123
|
}
|
|
131
124
|
|
|
132
|
-
export function createAuthManager(
|
|
133
|
-
const expectedPassword = Buffer.from(password)
|
|
125
|
+
export function createAuthManager(backend: AuthBackend, options: AuthManagerOptions = {}): AuthManager {
|
|
134
126
|
const trustProxy = options.trustProxy ?? false
|
|
135
|
-
const sessionStore = options.sessionStore
|
|
136
|
-
const getMaxAgeMs = options.getMaxAgeMs
|
|
137
|
-
const lastTouchedAt = new Map<string, number>()
|
|
138
127
|
|
|
139
|
-
function
|
|
140
|
-
|
|
128
|
+
function sessionHeaders(req: Request) {
|
|
129
|
+
const headers = new Headers()
|
|
130
|
+
const cookie = req.headers.get("cookie")
|
|
131
|
+
if (cookie) headers.set("cookie", cookie)
|
|
132
|
+
return headers
|
|
141
133
|
}
|
|
142
134
|
|
|
143
|
-
function
|
|
144
|
-
|
|
145
|
-
const last = lastTouchedAt.get(token)
|
|
146
|
-
if (last !== undefined && now - last < TOUCH_THROTTLE_MS) return
|
|
147
|
-
lastTouchedAt.set(token, now)
|
|
148
|
-
sessionStore.touch(token, getMaxAgeMs())
|
|
135
|
+
function hasSessionCookie(req: Request) {
|
|
136
|
+
return parseCookies(req.headers.get("cookie")).has(SESSION_COOKIE_NAME)
|
|
149
137
|
}
|
|
150
138
|
|
|
151
|
-
function
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
139
|
+
function withSecureAttribute(req: Request, setCookie: string) {
|
|
140
|
+
return shouldUseSecureCookie(req, trustProxy) ? `${setCookie}; Secure` : setCookie
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function appendCookies(response: Response, req: Request, setCookies: string[]) {
|
|
144
|
+
for (const setCookie of setCookies) {
|
|
145
|
+
response.headers.append("Set-Cookie", withSecureAttribute(req, setCookie))
|
|
158
146
|
}
|
|
159
|
-
|
|
160
|
-
|
|
147
|
+
return response
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readSession(req: Request) {
|
|
151
|
+
if (!hasSessionCookie(req)) return { authenticated: false, setCookies: [] }
|
|
152
|
+
return backend.getSession(sessionHeaders(req))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function isAuthenticated(req: Request) {
|
|
156
|
+
return (await readSession(req)).authenticated
|
|
161
157
|
}
|
|
162
158
|
|
|
163
159
|
function validateOrigin(req: Request) {
|
|
@@ -168,35 +164,37 @@ export function createAuthManager(password: string, options: AuthManagerOptions)
|
|
|
168
164
|
return origin === effectiveOrigin(req, trustProxy)
|
|
169
165
|
}
|
|
170
166
|
|
|
171
|
-
function
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function handleStatus(req: Request) {
|
|
196
|
-
|
|
167
|
+
function resolveClientIp(req: Request, socketIp: string | null) {
|
|
168
|
+
if (!trustProxy || !isLoopbackAddress(socketIp)) return socketIp
|
|
169
|
+
const cloudflare = req.headers.get("cf-connecting-ip")?.trim()
|
|
170
|
+
if (cloudflare) return cloudflare
|
|
171
|
+
const forwarded = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim()
|
|
172
|
+
return forwarded || socketIp
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function buildSignInRequest(req: Request, password: string, clientIp: string | null) {
|
|
176
|
+
const origin = effectiveOrigin(req, trustProxy)
|
|
177
|
+
const headers = new Headers({
|
|
178
|
+
"content-type": "application/json",
|
|
179
|
+
origin,
|
|
180
|
+
})
|
|
181
|
+
const userAgent = req.headers.get("user-agent")
|
|
182
|
+
if (userAgent) headers.set("user-agent", userAgent)
|
|
183
|
+
if (clientIp) headers.set(CLIENT_IP_HEADER, clientIp)
|
|
184
|
+
return new Request(`${origin}${SIGN_IN_PATH}`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers,
|
|
187
|
+
body: JSON.stringify({ email: OWNER_EMAIL, password, rememberMe: true }),
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function handleStatus(req: Request) {
|
|
192
|
+
const session = await readSession(req)
|
|
193
|
+
const response = Response.json({
|
|
197
194
|
enabled: true,
|
|
198
|
-
authenticated:
|
|
195
|
+
authenticated: session.authenticated,
|
|
199
196
|
} satisfies AuthStatusPayload)
|
|
197
|
+
return appendCookies(response, req, session.setCookies)
|
|
200
198
|
}
|
|
201
199
|
|
|
202
200
|
function redirectToApp(req: Request) {
|
|
@@ -204,35 +202,40 @@ export function createAuthManager(password: string, options: AuthManagerOptions)
|
|
|
204
202
|
return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
|
|
205
203
|
}
|
|
206
204
|
|
|
207
|
-
async function handleLogin(req: Request, fallbackNextPath: string) {
|
|
205
|
+
async function handleLogin(req: Request, fallbackNextPath: string, socketIp: string | null) {
|
|
208
206
|
if (!validateOrigin(req)) {
|
|
209
207
|
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
210
208
|
}
|
|
211
209
|
|
|
212
|
-
const { password
|
|
213
|
-
|
|
210
|
+
const { password, nextPath } = await readLoginForm(req)
|
|
211
|
+
const upstream = await backend.signIn(buildSignInRequest(req, password, resolveClientIp(req, socketIp)))
|
|
212
|
+
|
|
213
|
+
if (upstream.status === 429) {
|
|
214
|
+
const limited = Response.json({ error: "Too many attempts" }, { status: 429 })
|
|
215
|
+
const retryAfter = upstream.headers.get("retry-after")
|
|
216
|
+
if (retryAfter) limited.headers.set("Retry-After", retryAfter)
|
|
217
|
+
return limited
|
|
218
|
+
}
|
|
219
|
+
if (!upstream.ok) {
|
|
214
220
|
return Response.json({ error: "Invalid password" }, { status: 401 })
|
|
215
221
|
}
|
|
216
222
|
|
|
217
223
|
const response = Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
|
|
218
|
-
|
|
219
|
-
response.headers.set("Set-Cookie", createSessionCookie(req))
|
|
220
|
-
return response
|
|
224
|
+
return appendCookies(response, req, upstream.headers.getSetCookie())
|
|
221
225
|
}
|
|
222
226
|
|
|
223
|
-
function handleLogout(req: Request) {
|
|
227
|
+
async function handleLogout(req: Request) {
|
|
224
228
|
if (!validateOrigin(req)) {
|
|
225
229
|
return Response.json({ error: "Forbidden" }, { status: 403 })
|
|
226
230
|
}
|
|
227
231
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
return
|
|
232
|
+
const issued = hasSessionCookie(req) ? await backend.signOut(sessionHeaders(req)) : []
|
|
233
|
+
const clearing = issued.length > 0 ? issued : [buildCookie(SESSION_COOKIE_NAME, "", 0)]
|
|
234
|
+
return appendCookies(Response.json({ ok: true }), req, clearing)
|
|
231
235
|
}
|
|
232
236
|
|
|
233
237
|
async function dispose() {
|
|
234
|
-
|
|
235
|
-
await sessionStore.dispose()
|
|
238
|
+
await backend.dispose()
|
|
236
239
|
}
|
|
237
240
|
|
|
238
241
|
return {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { randomBytes } from "node:crypto"
|
|
3
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"
|
|
4
|
+
import path from "node:path"
|
|
5
|
+
import { betterAuth, type BetterAuthOptions } from "better-auth"
|
|
6
|
+
import { getMigrations } from "better-auth/db/migration"
|
|
7
|
+
import { isErrnoException, toError } from "../shared/errors"
|
|
8
|
+
import { log } from "../shared/log"
|
|
9
|
+
import type { AppSettingsManager } from "./app-settings"
|
|
10
|
+
import { createAuthManager, type AuthManager } from "./auth"
|
|
11
|
+
import { CLIENT_IP_HEADER, OWNER_EMAIL, SESSION_COOKIE_NAME, type AuthBackend } from "./auth-backend"
|
|
12
|
+
|
|
13
|
+
const SECRET_FILE = "auth-secret"
|
|
14
|
+
const DATABASE_FILE = "auth.db"
|
|
15
|
+
const LEGACY_SESSIONS_FILE = "sessions.json"
|
|
16
|
+
const SECONDS_PER_DAY = 86_400
|
|
17
|
+
const SESSION_UPDATE_AGE_SECONDS = SECONDS_PER_DAY
|
|
18
|
+
const AUTH_BASE_PATH = "/api/auth"
|
|
19
|
+
const INTERNAL_BASE_URL = "http://localhost"
|
|
20
|
+
const MAX_PASSWORD_LENGTH = 4096
|
|
21
|
+
const SECRET_BYTES = 32
|
|
22
|
+
|
|
23
|
+
async function loadOrCreateSecret(filePath: string): Promise<string> {
|
|
24
|
+
try {
|
|
25
|
+
const existing = (await readFile(filePath, "utf8")).trim()
|
|
26
|
+
if (existing) return existing
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (!isErrnoException(error) || error.code !== "ENOENT") throw error
|
|
29
|
+
}
|
|
30
|
+
const secret = randomBytes(SECRET_BYTES).toString("base64url")
|
|
31
|
+
await writeFile(filePath, secret, { mode: 0o600 })
|
|
32
|
+
return secret
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function removeLegacySessions(dataDir: string) {
|
|
36
|
+
await rm(path.join(dataDir, LEGACY_SESSIONS_FILE), { force: true })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function authOptions(database: Database, secret: string, sessionMaxAgeDays: number) {
|
|
40
|
+
return {
|
|
41
|
+
secret,
|
|
42
|
+
database,
|
|
43
|
+
baseURL: INTERNAL_BASE_URL,
|
|
44
|
+
basePath: AUTH_BASE_PATH,
|
|
45
|
+
emailAndPassword: {
|
|
46
|
+
enabled: true,
|
|
47
|
+
disableSignUp: true,
|
|
48
|
+
minPasswordLength: 1,
|
|
49
|
+
maxPasswordLength: MAX_PASSWORD_LENGTH,
|
|
50
|
+
},
|
|
51
|
+
session: {
|
|
52
|
+
expiresIn: Math.round(sessionMaxAgeDays * SECONDS_PER_DAY),
|
|
53
|
+
updateAge: SESSION_UPDATE_AGE_SECONDS,
|
|
54
|
+
},
|
|
55
|
+
advanced: {
|
|
56
|
+
cookies: { session_token: { name: SESSION_COOKIE_NAME } },
|
|
57
|
+
defaultCookieAttributes: { sameSite: "strict" },
|
|
58
|
+
useSecureCookies: false,
|
|
59
|
+
ipAddress: { ipAddressHeaders: [CLIENT_IP_HEADER] },
|
|
60
|
+
},
|
|
61
|
+
rateLimit: { enabled: true, storage: "database" },
|
|
62
|
+
trustedOrigins: (request) => (request ? [new URL(request.url).origin] : []),
|
|
63
|
+
logger: {
|
|
64
|
+
level: "warn",
|
|
65
|
+
log: (level, message, ...args) => log[level](`[auth] ${message}`, ...args),
|
|
66
|
+
},
|
|
67
|
+
} satisfies BetterAuthOptions
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildAuth(database: Database, secret: string, sessionMaxAgeDays: number) {
|
|
71
|
+
return betterAuth(authOptions(database, secret, sessionMaxAgeDays))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type KannaAuthInstance = ReturnType<typeof buildAuth>
|
|
75
|
+
|
|
76
|
+
async function ensureOwner(instance: KannaAuthInstance, password: string) {
|
|
77
|
+
const context = await instance.$context
|
|
78
|
+
const found = await context.internalAdapter.findUserByEmail(OWNER_EMAIL, { includeAccounts: true })
|
|
79
|
+
if (!found) {
|
|
80
|
+
const hash = await context.password.hash(password)
|
|
81
|
+
const user = await context.internalAdapter.createUser(
|
|
82
|
+
{ email: OWNER_EMAIL, name: "Owner", emailVerified: true },
|
|
83
|
+
{ method: "email-password" },
|
|
84
|
+
)
|
|
85
|
+
await context.internalAdapter.linkAccount({
|
|
86
|
+
userId: user.id,
|
|
87
|
+
providerId: "credential",
|
|
88
|
+
accountId: user.id,
|
|
89
|
+
password: hash,
|
|
90
|
+
})
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const credential = found.accounts.find((account) => account.providerId === "credential")
|
|
94
|
+
const unchanged = credential?.password
|
|
95
|
+
? await context.password.verify({ hash: credential.password, password })
|
|
96
|
+
: false
|
|
97
|
+
if (unchanged) return
|
|
98
|
+
const hash = await context.password.hash(password)
|
|
99
|
+
if (credential) {
|
|
100
|
+
await context.internalAdapter.updatePassword(found.user.id, hash)
|
|
101
|
+
} else {
|
|
102
|
+
await context.internalAdapter.linkAccount({
|
|
103
|
+
userId: found.user.id,
|
|
104
|
+
providerId: "credential",
|
|
105
|
+
accountId: found.user.id,
|
|
106
|
+
password: hash,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
await context.internalAdapter.deleteUserSessions(found.user.id)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface BetterAuthBackendOptions {
|
|
113
|
+
dataDir: string
|
|
114
|
+
password: string
|
|
115
|
+
getSessionMaxAgeDays: () => number
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface BetterAuthBackend extends AuthBackend {
|
|
119
|
+
refreshSessionLifetime(): void
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function createBetterAuthBackend(options: BetterAuthBackendOptions): Promise<BetterAuthBackend> {
|
|
123
|
+
await mkdir(options.dataDir, { recursive: true })
|
|
124
|
+
const secret = await loadOrCreateSecret(path.join(options.dataDir, SECRET_FILE))
|
|
125
|
+
const databasePath = path.join(options.dataDir, DATABASE_FILE)
|
|
126
|
+
const database = new Database(databasePath, { create: true })
|
|
127
|
+
await chmod(databasePath, 0o600)
|
|
128
|
+
let configuredDays = options.getSessionMaxAgeDays()
|
|
129
|
+
let current: KannaAuthInstance
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const { runMigrations } = await getMigrations(authOptions(database, secret, configuredDays))
|
|
133
|
+
await runMigrations()
|
|
134
|
+
current = buildAuth(database, secret, configuredDays)
|
|
135
|
+
await ensureOwner(current, options.password)
|
|
136
|
+
await removeLegacySessions(options.dataDir)
|
|
137
|
+
} catch (error) {
|
|
138
|
+
database.close()
|
|
139
|
+
throw toError(error)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
refreshSessionLifetime() {
|
|
144
|
+
const days = options.getSessionMaxAgeDays()
|
|
145
|
+
if (days === configuredDays) return
|
|
146
|
+
configuredDays = days
|
|
147
|
+
current = buildAuth(database, secret, days)
|
|
148
|
+
},
|
|
149
|
+
async signIn(request) {
|
|
150
|
+
return current.handler(request)
|
|
151
|
+
},
|
|
152
|
+
async getSession(headers) {
|
|
153
|
+
const result = await current.api.getSession({ headers, returnHeaders: true })
|
|
154
|
+
return {
|
|
155
|
+
authenticated: result.response !== null,
|
|
156
|
+
setCookies: result.headers.getSetCookie(),
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
async signOut(headers) {
|
|
160
|
+
try {
|
|
161
|
+
const result = await current.api.signOut({ headers, returnHeaders: true })
|
|
162
|
+
return result.headers.getSetCookie()
|
|
163
|
+
} catch (error) {
|
|
164
|
+
log.warn("[auth] sign-out failed", toError(error).message)
|
|
165
|
+
return []
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async dispose() {
|
|
169
|
+
database.close()
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface PasswordAuthOptions {
|
|
175
|
+
dataDir: string
|
|
176
|
+
password: string
|
|
177
|
+
trustProxy: boolean
|
|
178
|
+
appSettings: AppSettingsManager
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function createPasswordAuth(options: PasswordAuthOptions): Promise<AuthManager> {
|
|
182
|
+
const { appSettings } = options
|
|
183
|
+
const backend = await createBetterAuthBackend({
|
|
184
|
+
dataDir: options.dataDir,
|
|
185
|
+
password: options.password,
|
|
186
|
+
getSessionMaxAgeDays: () => appSettings.getSnapshot().auth.sessionMaxAgeDays,
|
|
187
|
+
})
|
|
188
|
+
const unsubscribe = appSettings.onChange(() => backend.refreshSessionLifetime())
|
|
189
|
+
const manager = createAuthManager(backend, { trustProxy: options.trustProxy })
|
|
190
|
+
return {
|
|
191
|
+
...manager,
|
|
192
|
+
async dispose() {
|
|
193
|
+
unsubscribe()
|
|
194
|
+
await manager.dispose()
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -111,7 +111,7 @@ export function createHttpDispatcher(
|
|
|
111
111
|
|
|
112
112
|
if (url.pathname === "/auth/status") {
|
|
113
113
|
return auth
|
|
114
|
-
? auth.handleStatus(req)
|
|
114
|
+
? await auth.handleStatus(req)
|
|
115
115
|
: Response.json({ enabled: false, authenticated: true })
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -119,7 +119,7 @@ export function createHttpDispatcher(
|
|
|
119
119
|
if (req.method !== "POST") {
|
|
120
120
|
return new Response(null, { status: 405, headers: { Allow: "POST" } })
|
|
121
121
|
}
|
|
122
|
-
return auth ? auth.handleLogout(req) : Response.json({ ok: true })
|
|
122
|
+
return auth ? await auth.handleLogout(req) : Response.json({ ok: true })
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
if (url.pathname.startsWith("/api/share/")) {
|
|
@@ -129,14 +129,14 @@ export function createHttpDispatcher(
|
|
|
129
129
|
if (auth) {
|
|
130
130
|
if (url.pathname === "/auth/login") {
|
|
131
131
|
if (req.method === "GET") return auth.redirectToApp(req)
|
|
132
|
-
if (req.method === "POST") return auth.handleLogin(req, "/")
|
|
132
|
+
if (req.method === "POST") return auth.handleLogin(req, "/", server.requestIP(req)?.address ?? null)
|
|
133
133
|
return new Response(null, { status: 405, headers: { Allow: "GET, POST" } })
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
if (url.pathname === "/ws") {
|
|
137
137
|
if (!auth.validateOrigin(req)) return new Response("Forbidden", { status: 403 })
|
|
138
|
-
if (!auth.isAuthenticated(req)) return new Response("Unauthorized", { status: 401 })
|
|
139
|
-
} else if (url.pathname.startsWith("/api/") && !auth.isAuthenticated(req)) {
|
|
138
|
+
if (!(await auth.isAuthenticated(req))) return new Response("Unauthorized", { status: 401 })
|
|
139
|
+
} else if (url.pathname.startsWith("/api/") && !(await auth.isAuthenticated(req))) {
|
|
140
140
|
return Response.json({ error: "Unauthorized" }, { status: 401 })
|
|
141
141
|
}
|
|
142
142
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
type AppSettingsSnapshot,
|
|
10
10
|
} from "../shared/types"
|
|
11
11
|
import type { ShareMode } from "../shared/share"
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import type { AuthManager } from "./auth"
|
|
13
|
+
import { createPasswordAuth } from "./better-auth.adapter"
|
|
14
14
|
import { EventStore } from "./event-store"
|
|
15
15
|
import { PushManager, realWebPushSender } from "./push/push-manager"
|
|
16
16
|
import { loadOrGenerateVapidKeys } from "./push/vapid.adapter"
|
|
@@ -159,7 +159,7 @@ export interface StartKannaServerOptions {
|
|
|
159
159
|
interface ApplicationServices {
|
|
160
160
|
store: EventStore
|
|
161
161
|
diffStore: DiffStore
|
|
162
|
-
auth:
|
|
162
|
+
auth: AuthManager | null
|
|
163
163
|
analytics: KannaAnalyticsReporter
|
|
164
164
|
terminals: TerminalManager
|
|
165
165
|
updateManager: UpdateManager | null
|
|
@@ -274,12 +274,11 @@ async function createApplicationServices(options: StartKannaServerOptions): Prom
|
|
|
274
274
|
const snapshotSweepHandle = startSnapshotSweep(sessionShareService, 24 * 60 * 60 * 1000)
|
|
275
275
|
await keybindings.initialize()
|
|
276
276
|
const auth = options.password
|
|
277
|
-
?
|
|
277
|
+
? await createPasswordAuth({
|
|
278
|
+
dataDir: store.dataDir,
|
|
279
|
+
password: options.password,
|
|
278
280
|
trustProxy: options.trustProxy ?? false,
|
|
279
|
-
|
|
280
|
-
filePath: path.join(store.dataDir, "sessions.json"),
|
|
281
|
-
}),
|
|
282
|
-
getMaxAgeMs: () => appSettings.getSnapshot().auth.sessionMaxAgeDays * 86_400_000,
|
|
281
|
+
appSettings,
|
|
283
282
|
})
|
|
284
283
|
: null
|
|
285
284
|
const analytics = new KannaAnalyticsReporter({
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{f as e}from"./pendingActionsStore-CWNaCyjS.js";var t={name:`chevron-left`,size:24,node:[[`path`,{d:`m15 18-6-6 6-6`,key:`1wnfg3`}]]};t.node;var n=e(t);export{n as t};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{i as e,m as t,t as n}from"./log-Ch_SNBXH.js";function r(r,i){i.then(void 0,t(t=>n.error(e,`${r} failed`,t)))}export{r as t};
|