kaneo (minimalist kanban) fork to experiment adding a tangled integration
github.com/usekaneo/kaneo
1import { createHash, randomUUID } from "node:crypto";
2import { eq } from "drizzle-orm";
3import { beforeEach, describe, expect, it } from "vitest";
4import db, { schema } from "../../apps/api/src/database";
5import { createApp } from "../../apps/api/src/index";
6import { resetTestDatabase } from "./helpers/database";
7import { createWorkspaceMember } from "./helpers/fixtures";
8
9const origin = "http://localhost:5173";
10
11function mergeCookieJar(cookieJar: string, res: Response): string {
12 const incoming = res.headers.getSetCookie?.() ?? [];
13 if (incoming.length === 0) {
14 return cookieJar;
15 }
16 const pairs = incoming.map((c) => c.split(";")[0]).filter(Boolean);
17 const prefix = cookieJar ? `${cookieJar}; ` : "";
18 return `${prefix}${pairs.join("; ")}`;
19}
20
21async function hashApiKeyForTest(key: string): Promise<string> {
22 const hash = createHash("sha256").update(key).digest();
23 return hash
24 .toString("base64")
25 .replace(/\+/g, "-")
26 .replace(/\//g, "_")
27 .replace(/=/g, "");
28}
29
30async function signUpAndGetCookie(
31 app: ReturnType<typeof createApp>["app"],
32 email: string,
33 password: string,
34): Promise<string> {
35 let jar = "csrf=1";
36 const signUp = await app.request("/api/auth/sign-up/email", {
37 method: "POST",
38 headers: {
39 "content-type": "application/json",
40 Origin: origin,
41 Cookie: jar,
42 },
43 body: JSON.stringify({
44 name: "Device flow user",
45 email,
46 password,
47 }),
48 });
49
50 expect(signUp.status).toBe(200);
51 jar = mergeCookieJar(jar, signUp);
52
53 const signIn = await app.request("/api/auth/sign-in/email", {
54 method: "POST",
55 headers: {
56 "content-type": "application/json",
57 Origin: origin,
58 Cookie: jar,
59 },
60 body: JSON.stringify({ email, password }),
61 });
62
63 expect(signIn.status).toBe(200);
64 jar = mergeCookieJar(jar, signIn);
65 return jar;
66}
67
68describe("API integration: device authorization (RFC 8628)", () => {
69 beforeEach(async () => {
70 await resetTestDatabase();
71 });
72
73 it("returns device and user codes for an allowed client_id", async () => {
74 const { app } = createApp();
75
76 const res = await app.request("/api/auth/device/code", {
77 method: "POST",
78 headers: {
79 "content-type": "application/json",
80 Origin: origin,
81 },
82 body: JSON.stringify({ client_id: "kaneo-cli" }),
83 });
84
85 expect(res.status).toBe(200);
86 const body = (await res.json()) as Record<string, unknown>;
87 expect(body.device_code).toEqual(expect.any(String));
88 expect(body.user_code).toEqual(expect.any(String));
89 expect(body.verification_uri).toEqual(expect.any(String));
90 expect(body.interval).toEqual(expect.any(Number));
91 expect(body.expires_in).toEqual(expect.any(Number));
92 });
93
94 it("rejects disallowed client_id", async () => {
95 const { app } = createApp();
96
97 const res = await app.request("/api/auth/device/code", {
98 method: "POST",
99 headers: {
100 "content-type": "application/json",
101 Origin: origin,
102 },
103 body: JSON.stringify({ client_id: "unknown-client" }),
104 });
105
106 expect(res.status).toBe(400);
107 const body = (await res.json()) as { error?: string };
108 expect(body.error).toBe("invalid_client");
109 });
110
111 it("returns authorization_pending before approval", async () => {
112 const { app } = createApp();
113
114 const codeRes = await app.request("/api/auth/device/code", {
115 method: "POST",
116 headers: {
117 "content-type": "application/json",
118 Origin: origin,
119 },
120 body: JSON.stringify({ client_id: "kaneo-cli" }),
121 });
122 const { device_code } = (await codeRes.json()) as { device_code: string };
123
124 const tokenRes = await app.request("/api/auth/device/token", {
125 method: "POST",
126 headers: {
127 "content-type": "application/json",
128 Origin: origin,
129 },
130 body: JSON.stringify({
131 grant_type: "urn:ietf:params:oauth:grant-type:device_code",
132 device_code,
133 client_id: "kaneo-cli",
134 }),
135 });
136
137 expect(tokenRes.status).toBe(400);
138 const body = (await tokenRes.json()) as { error: string };
139 expect(body.error).toBe("authorization_pending");
140 });
141
142 it("issues an access token after approval and allows API access with Bearer", async () => {
143 const email = `device-${randomUUID()}@example.com`;
144 const password = "device-flow-password-12345";
145
146 const { app } = createApp();
147 const cookieJar = await signUpAndGetCookie(app, email, password);
148
149 const sessionRes = await app.request("/api/auth/get-session", {
150 headers: {
151 Cookie: cookieJar,
152 Origin: origin,
153 },
154 });
155 expect(sessionRes.status).toBe(200);
156 const sessionJson = (await sessionRes.json()) as {
157 user?: { id: string };
158 };
159 const userId = sessionJson.user?.id;
160 if (!userId) {
161 throw new Error("expected session user id after sign-in");
162 }
163
164 const workspaceId = `ws-${randomUUID()}`;
165 await db.insert(schema.workspaceTable).values({
166 id: workspaceId,
167 name: "Device test workspace",
168 slug: `slug-${randomUUID()}`,
169 createdAt: new Date(),
170 });
171 await db.insert(schema.workspaceUserTable).values({
172 workspaceId,
173 userId,
174 role: "owner",
175 joinedAt: new Date(),
176 });
177
178 const codeRes = await app.request("/api/auth/device/code", {
179 method: "POST",
180 headers: {
181 "content-type": "application/json",
182 Origin: origin,
183 },
184 body: JSON.stringify({ client_id: "kaneo-cli" }),
185 });
186 expect(codeRes.status).toBe(200);
187 const devicePayload = (await codeRes.json()) as {
188 device_code: string;
189 user_code: string;
190 interval: number;
191 };
192
193 const approveRes = await app.request("/api/auth/device/approve", {
194 method: "POST",
195 headers: {
196 "content-type": "application/json",
197 Origin: origin,
198 Cookie: cookieJar,
199 },
200 body: JSON.stringify({
201 userCode: devicePayload.user_code,
202 }),
203 });
204 expect(approveRes.status).toBe(200);
205
206 let accessToken: string | undefined;
207 const maxAttempts = 40;
208 for (let i = 0; i < maxAttempts; i++) {
209 if (i > 0) {
210 await new Promise((r) =>
211 setTimeout(r, devicePayload.interval * 1000 + 50),
212 );
213 }
214 const tokenRes = await app.request("/api/auth/device/token", {
215 method: "POST",
216 headers: {
217 "content-type": "application/json",
218 Origin: origin,
219 },
220 body: JSON.stringify({
221 grant_type: "urn:ietf:params:oauth:grant-type:device_code",
222 device_code: devicePayload.device_code,
223 client_id: "kaneo-cli",
224 }),
225 });
226 if (tokenRes.status === 200) {
227 const t = (await tokenRes.json()) as { access_token?: string };
228 accessToken = t.access_token;
229 break;
230 }
231 const err = (await tokenRes.json()) as { error: string };
232 if (err.error !== "authorization_pending" && err.error !== "slow_down") {
233 throw new Error(`Unexpected token error: ${err.error}`);
234 }
235 }
236
237 expect(accessToken).toBeTruthy();
238
239 const organizationsRes = await app.request("/api/auth/organization/list", {
240 headers: {
241 Authorization: `Bearer ${accessToken}`,
242 },
243 });
244 expect(organizationsRes.status).toBe(200);
245 const organizations = (await organizationsRes.json()) as unknown[];
246 expect(Array.isArray(organizations)).toBe(true);
247
248 const projectsRes = await app.request(
249 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`,
250 {
251 headers: {
252 Authorization: `Bearer ${accessToken}`,
253 },
254 },
255 );
256 expect(projectsRes.status).toBe(200);
257 const projects = (await projectsRes.json()) as unknown[];
258 expect(Array.isArray(projects)).toBe(true);
259 });
260
261 it("still authenticates with a valid API key Bearer", async () => {
262 const member = await createWorkspaceMember();
263
264 const rawKey = `kaneo_test_${randomUUID()}`;
265 const hashed = await hashApiKeyForTest(rawKey);
266 const now = new Date();
267
268 await db.insert(schema.apikeyTable).values({
269 referenceId: member.user.id,
270 userId: member.user.id,
271 key: hashed,
272 name: "integration device test",
273 start: rawKey.slice(0, 12),
274 prefix: "kaneo",
275 createdAt: now,
276 updatedAt: now,
277 });
278
279 const { app } = createApp();
280
281 const res = await app.request(
282 `/api/project?workspaceId=${encodeURIComponent(member.workspace.id)}`,
283 {
284 headers: {
285 Authorization: `Bearer ${rawKey}`,
286 },
287 },
288 );
289
290 expect(res.status).toBe(200);
291
292 const rows = await db
293 .select()
294 .from(schema.apikeyTable)
295 .where(eq(schema.apikeyTable.key, hashed));
296 expect(rows.length).toBe(1);
297 });
298
299 it("accepts a created API key Bearer on auth routes", async () => {
300 const member = await createWorkspaceMember();
301 const rawKey =
302 randomUUID().replace(/-/g, "") + randomUUID().replace(/-/g, "");
303 const hashed = await hashApiKeyForTest(rawKey);
304 const now = new Date();
305
306 await db.insert(schema.apikeyTable).values({
307 referenceId: member.user.id,
308 userId: member.user.id,
309 key: hashed,
310 name: "auth route api key",
311 start: rawKey.slice(0, 12),
312 createdAt: now,
313 updatedAt: now,
314 });
315
316 const { app } = createApp();
317
318 const authRouteRes = await app.request("/api/auth/organization/list", {
319 headers: {
320 Authorization: `Bearer ${rawKey}`,
321 },
322 });
323
324 expect(authRouteRes.status).toBe(200);
325 });
326
327 it("rejects an invalid Bearer token even when a valid session cookie is present", async () => {
328 const email = `device-bearer-${randomUUID()}@example.com`;
329 const password = "device-flow-password-12345";
330
331 const { app } = createApp();
332 const cookieJar = await signUpAndGetCookie(app, email, password);
333
334 const sessionRes = await app.request("/api/auth/get-session", {
335 headers: {
336 Cookie: cookieJar,
337 Origin: origin,
338 },
339 });
340 expect(sessionRes.status).toBe(200);
341 const sessionJson = (await sessionRes.json()) as {
342 user?: { id: string };
343 };
344 const userId = sessionJson.user?.id;
345 if (!userId) {
346 throw new Error("expected session user id after sign-in");
347 }
348
349 const workspaceId = `ws-${randomUUID()}`;
350 await db.insert(schema.workspaceTable).values({
351 id: workspaceId,
352 name: "Bearer fallback workspace",
353 slug: `slug-${randomUUID()}`,
354 createdAt: new Date(),
355 });
356 await db.insert(schema.workspaceUserTable).values({
357 workspaceId,
358 userId,
359 role: "owner",
360 joinedAt: new Date(),
361 });
362
363 const res = await app.request(
364 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`,
365 {
366 headers: {
367 Authorization: "Bearer definitely-not-a-real-token",
368 Cookie: cookieJar,
369 Origin: origin,
370 },
371 },
372 );
373
374 expect(res.status).toBe(401);
375
376 const lowercaseSchemeRes = await app.request(
377 `/api/project?workspaceId=${encodeURIComponent(workspaceId)}`,
378 {
379 headers: {
380 authorization: "bearer definitely-not-a-real-token",
381 Cookie: cookieJar,
382 Origin: origin,
383 },
384 },
385 );
386
387 expect(lowercaseSchemeRes.status).toBe(401);
388 });
389});