kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { apiKey } from "@better-auth/api-key";
2import {
3 sendMagicLinkEmail,
4 sendOtpEmail,
5 sendWorkspaceInvitationEmail,
6} from "@kaneo/email";
7import bcrypt from "bcrypt";
8import { betterAuth } from "better-auth";
9import { drizzleAdapter } from "better-auth/adapters/drizzle";
10import { APIError, createAuthMiddleware } from "better-auth/api";
11import {
12 anonymous,
13 bearer,
14 deviceAuthorization,
15 emailOTP,
16 genericOAuth,
17 lastLoginMethod,
18 magicLink,
19 openAPI,
20 organization,
21} from "better-auth/plugins";
22import { config } from "dotenv-mono";
23import { eq } from "drizzle-orm";
24import db, { schema } from "./database";
25import { publishEvent } from "./events";
26import { checkRegistrationAllowed } from "./utils/check-registration-allowed";
27import { generateDemoName } from "./utils/generate-demo-name";
28import { getGithubSsoOAuthCredentials } from "./utils/github-sso-env";
29
30config();
31
32const githubSso = getGithubSsoOAuthCredentials();
33
34const isRegistrationDisabled = process.env.DISABLE_REGISTRATION === "true";
35const isPasswordRegistrationDisabled =
36 process.env.DISABLE_PASSWORD_REGISTRATION === "true";
37
38const apiUrl = process.env.KANEO_API_URL || "http://localhost:1337";
39const clientUrl = process.env.KANEO_CLIENT_URL || "http://localhost:5173";
40const isHttps = apiUrl.startsWith("https://");
41const isCrossSubdomain = (() => {
42 try {
43 const apiHost = new URL(apiUrl).hostname;
44 const clientHost = new URL(clientUrl).hostname;
45 return (
46 apiHost !== clientHost &&
47 apiHost !== "localhost" &&
48 clientHost !== "localhost"
49 );
50 } catch {
51 return false;
52 }
53})();
54
55const trustedOrigins = [clientUrl];
56try {
57 const apiOrigin = new URL(apiUrl);
58 const apiOriginString = `${apiOrigin.protocol}//${apiOrigin.host}`;
59 if (!trustedOrigins.includes(apiOriginString)) {
60 trustedOrigins.push(apiOriginString);
61 }
62} catch {}
63
64const baseURLWithoutPath = (() => {
65 try {
66 const url = new URL(apiUrl);
67 return `${url.protocol}//${url.host}`;
68 } catch {
69 return apiUrl.split("/").slice(0, 3).join("/"); // Get protocol://host
70 }
71})();
72
73if (process.env.AUTH_SECRET && process.env.AUTH_SECRET.length < 32) {
74 console.error(
75 "AUTH_SECRET is less than 32 characters, please generate a new one.",
76 );
77 process.exit(1);
78}
79
80async function getUserLocale(email: string) {
81 const [user] = await db
82 .select({ locale: schema.userTable.locale })
83 .from(schema.userTable)
84 .where(eq(schema.userTable.email, email))
85 .limit(1);
86
87 return user?.locale ?? null;
88}
89
90function getLocaleKey(locale?: string | null) {
91 return locale?.toLowerCase().startsWith("de") ? "de" : "en";
92}
93
94function getAuthEmailCopy(locale?: string | null) {
95 return getLocaleKey(locale) === "de"
96 ? {
97 magicLinkSubject: "Anmeldelink fuer Kaneo",
98 otpSubject: "Bestaetigungscode fuer Kaneo",
99 }
100 : {
101 magicLinkSubject: "Login for Kaneo",
102 otpSubject: "Authentication code for Kaneo",
103 };
104}
105
106function getDeviceAuthClientIds(): Set<string> {
107 const raw = process.env.DEVICE_AUTH_CLIENT_IDS?.trim();
108 if (raw) {
109 return new Set(
110 raw
111 .split(",")
112 .map((s) => s.trim())
113 .filter(Boolean),
114 );
115 }
116 return new Set(["kaneo-cli", "kaneo-mcp"]);
117}
118
119function getDeviceAuthVerificationUri(): string {
120 const base = clientUrl.replace(/\/$/, "");
121 return `${base}/device`;
122}
123
124function getInvitationEmailSubject(
125 locale: string | null,
126 inviterName: string,
127 workspaceName: string,
128) {
129 return getLocaleKey(locale) === "de"
130 ? `${inviterName} hat dich eingeladen, ${workspaceName} auf Kaneo beizutreten`
131 : `${inviterName} invited you to join ${workspaceName} on Kaneo`;
132}
133
134export const auth = betterAuth({
135 baseURL: baseURLWithoutPath,
136 trustedOrigins,
137 secret: process.env.AUTH_SECRET || "",
138 basePath: "/api/auth",
139 database: drizzleAdapter(db, {
140 provider: "pg",
141 schema: {
142 ...schema,
143 user: schema.userTable,
144 account: schema.accountTable,
145 session: schema.sessionTable,
146 verification: schema.verificationTable,
147 workspace: schema.workspaceTable,
148 workspace_member: schema.workspaceUserTable,
149 invitation: schema.invitationTable,
150 team: schema.teamTable,
151 teamMember: schema.teamMemberTable,
152 apikey: schema.apikeyTable,
153 deviceCode: schema.deviceCodeTable,
154 },
155 }),
156 user: {
157 additionalFields: {
158 locale: {
159 type: "string",
160 input: true,
161 required: false,
162 },
163 },
164 },
165 emailAndPassword: {
166 enabled: true,
167 autoSignIn: true,
168 password: {
169 hash: async (password) => {
170 return await bcrypt.hash(password, 10);
171 },
172 verify: async ({ hash, password }) => {
173 return await bcrypt.compare(password, hash);
174 },
175 },
176 },
177 socialProviders: {
178 github: {
179 clientId: githubSso.clientId,
180 clientSecret: githubSso.clientSecret,
181 scope: ["user:email"],
182 },
183 google: {
184 clientId: process.env.GOOGLE_CLIENT_ID || "",
185 clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
186 },
187 discord: {
188 clientId: process.env.DISCORD_CLIENT_ID || "",
189 clientSecret: process.env.DISCORD_CLIENT_SECRET || "",
190 },
191 },
192 plugins: [
193 ...(process.env.DISABLE_GUEST_ACCESS !== "true"
194 ? [
195 anonymous({
196 generateName: async () => generateDemoName(),
197 emailDomainName: "kaneo.app",
198 }),
199 ]
200 : []),
201 lastLoginMethod(),
202 magicLink({
203 sendMagicLink: async ({ email, url }) => {
204 try {
205 const locale = await getUserLocale(email);
206 const copy = getAuthEmailCopy(locale);
207 await sendMagicLinkEmail(email, copy.magicLinkSubject, {
208 magicLink: url,
209 locale,
210 });
211 } catch (error) {
212 console.error(error);
213 }
214 },
215 }),
216 emailOTP({
217 async sendVerificationOTP({ email, otp, type }) {
218 if (type === "sign-in") {
219 const locale = await getUserLocale(email);
220 const copy = getAuthEmailCopy(locale);
221 await sendOtpEmail(email, copy.otpSubject, {
222 otp,
223 locale,
224 });
225 }
226 },
227 }),
228 organization({
229 // creatorRole: "admin", // maybe will want this "The role of the user who creates the organization."
230 // invitationLimit and other fields like this may be beneficial as well
231 teams: {
232 enabled: true,
233 maximumTeams: 10,
234 allowRemovingAllTeams: false,
235 },
236 schema: {
237 organization: {
238 modelName: "workspace",
239 additionalFields: {
240 // in metadata
241 description: {
242 type: "string",
243 input: true,
244 required: false,
245 },
246 },
247 },
248 member: {
249 modelName: "workspace_member",
250 fields: {
251 organizationId: "workspaceId",
252 createdAt: "joinedAt",
253 },
254 },
255 invitation: {
256 modelName: "invitation",
257 fields: {
258 organizationId: "workspaceId",
259 },
260 },
261 team: {
262 modelName: "team",
263 fields: {
264 organizationId: "workspaceId",
265 },
266 },
267 },
268 allowUserToCreateOrganization: true,
269 organizationHooks: {
270 afterCreateOrganization: async ({ organization, user }) => {
271 publishEvent("workspace.created", {
272 workspaceId: organization.id,
273 workspaceName: organization.name,
274 ownerEmail: user.name,
275 ownerId: user.id,
276 });
277 },
278 },
279 async sendInvitationEmail(data) {
280 const inviteLink = `${process.env.KANEO_CLIENT_URL}/invitation/accept/${data.id}`;
281 const locale = await getUserLocale(data.email);
282
283 const result = await sendWorkspaceInvitationEmail(
284 data.email,
285 getInvitationEmailSubject(
286 locale,
287 data.inviter.user.name,
288 data.organization.name,
289 ),
290 {
291 inviterEmail: data.inviter.user.email,
292 inviterName: data.inviter.user.name,
293 locale,
294 workspaceName: data.organization.name,
295 invitationLink: inviteLink,
296 to: data.email,
297 },
298 );
299
300 if (
301 result?.success === false &&
302 result.reason === "SMTP_NOT_CONFIGURED"
303 ) {
304 console.warn(
305 "Invitation created but email not sent due to SMTP not being configured",
306 );
307 return;
308 }
309 },
310 }),
311 genericOAuth({
312 config: [
313 {
314 providerId: "custom",
315 clientId: process.env.CUSTOM_OAUTH_CLIENT_ID || "",
316 clientSecret: process.env.CUSTOM_OAUTH_CLIENT_SECRET,
317 authorizationUrl: process.env.CUSTOM_OAUTH_AUTHORIZATION_URL || "",
318 tokenUrl: process.env.CUSTOM_OAUTH_TOKEN_URL || "",
319 userInfoUrl: process.env.CUSTOM_OAUTH_USER_INFO_URL || "",
320 scopes: process.env.CUSTOM_OAUTH_SCOPES?.split(",")
321 .map((s) => s.trim())
322 .filter(Boolean) || ["profile", "email"],
323 responseType: process.env.CUSTOM_OAUTH_RESPONSE_TYPE || "code",
324 discoveryUrl: process.env.CUSTOM_OAUTH_DISCOVERY_URL || "",
325 pkce: process.env.CUSTOM_AUTH_PKCE !== "false",
326 },
327 ],
328 }),
329 bearer(),
330 apiKey({
331 enableSessionForAPIKeys: true,
332 apiKeyHeaders: "x-api-key",
333 rateLimit: {
334 enabled: true,
335 maxRequests: 100,
336 timeWindow: 60 * 1000,
337 },
338 }),
339 deviceAuthorization({
340 verificationUri: getDeviceAuthVerificationUri(),
341 validateClient: async (clientId) =>
342 getDeviceAuthClientIds().has(clientId),
343 }),
344 openAPI(),
345 ],
346 session: {
347 cookieCache: {
348 enabled: true,
349 maxAge: 5 * 60,
350 },
351 },
352 databaseHooks: {
353 user: {
354 create: {
355 before: async (user) => {
356 const result = await checkRegistrationAllowed(user.email);
357 if (!result.allowed) {
358 throw new APIError("FORBIDDEN", {
359 message: result.reason,
360 });
361 }
362 },
363 },
364 },
365 },
366 hooks: {
367 before: createAuthMiddleware(async (ctx) => {
368 const isSignUpPath =
369 ctx.path === "/sign-up/email" ||
370 ctx.path.startsWith("/callback/") ||
371 ctx.path.startsWith("/sign-in/social");
372
373 if (!isSignUpPath) {
374 return;
375 }
376
377 if (ctx.path === "/sign-up/email") {
378 if (isPasswordRegistrationDisabled) {
379 throw new APIError("FORBIDDEN", {
380 message:
381 "Password registration is currently disabled. Please use a configured social or OIDC sign-in method.",
382 });
383 }
384 }
385
386 if (!isRegistrationDisabled) {
387 return;
388 }
389
390 const email =
391 ctx.body?.email ||
392 ctx.query?.email ||
393 ctx.headers?.get("x-invitation-email");
394 const invitationId =
395 ctx.body?.invitationId ||
396 ctx.query?.invitationId ||
397 ctx.headers?.get("x-invitation-id");
398
399 if (ctx.path === "/sign-up/email") {
400 const result = await checkRegistrationAllowed(email, invitationId);
401 if (!result.allowed) {
402 throw new APIError("FORBIDDEN", {
403 message: result.reason,
404 });
405 }
406 }
407 }),
408 after: createAuthMiddleware(async (ctx) => {
409 if (ctx.path.startsWith("/sign-up") || ctx.path.startsWith("/sign-in")) {
410 const newSession = ctx.context.newSession;
411 if (newSession) {
412 const workspaceMember = await db
413 .select({ workspaceId: schema.workspaceUserTable.workspaceId })
414 .from(schema.workspaceUserTable)
415 .where(eq(schema.workspaceUserTable.userId, newSession.user.id))
416 .limit(1);
417
418 const activeWorkspaceId = workspaceMember[0]?.workspaceId || null;
419
420 if (activeWorkspaceId) {
421 await db
422 .update(schema.sessionTable)
423 .set({ activeOrganizationId: activeWorkspaceId })
424 .where(eq(schema.sessionTable.id, newSession.session.id));
425 }
426 }
427 }
428 }),
429 },
430 advanced: {
431 defaultCookieAttributes: {
432 // For cross-subdomain auth with HTTPS, use sameSite: "none" with secure: true
433 // For same-domain or HTTP deployments, use sameSite: "lax" with secure: false
434 sameSite: isCrossSubdomain && isHttps ? "none" : "lax",
435 secure: isCrossSubdomain && isHttps, // must be true when sameSite is "none"
436 partitioned: isCrossSubdomain && isHttps,
437 domain: process.env.COOKIE_DOMAIN || undefined, // Optional: e.g., ".andrej.com" for explicit cross-subdomain cookies
438 },
439 },
440});